From 37e9e2c8f0f5c5708bf965df43636556a8121c40 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:05:56 -0500 Subject: [PATCH 001/114] fix(charts): hoist rememberVicoZoomState above vararg layers to prevent ClassCastException (#5060) --- .../org/meshtastic/feature/node/metrics/BaseMetricChart.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index 0b9f40044..cb96607d9 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -96,6 +96,11 @@ fun GenericMetricChart( onPointSelected: ((Double) -> Unit)? = null, vicoScrollState: VicoScrollState = rememberVicoScrollState(), ) { + // Hoist zoom state above rememberCartesianChart so that the variable slot count + // from the vararg layers spread does not shift this remember call during recomposition + // (toggling legend chips changes the layer count, which corrupts the slot table). + val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content) + val markerVisibilityListener = remember(onPointSelected) { object : CartesianMarkerVisibilityListener { @@ -126,7 +131,7 @@ fun GenericMetricChart( modelProducer = modelProducer, modifier = modifier, scrollState = vicoScrollState, - zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content), + zoomState = zoomState, ) } From a6423d0a0f4255c92a1af46f5522fc8d45ed441a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:26:26 -0500 Subject: [PATCH 002/114] feat(metrics): redesign position log with SelectableMetricCard and add CSV export to all metrics screens (#5062) --- .../meshtastic/app/map/MapViewExtensions.kt | 23 +-- .../meshtastic/app/map/node/NodeTrackMap.kt | 12 +- .../app/map/node/NodeTrackOsmMap.kt | 14 +- .../kotlin/org/meshtastic/app/map/MapView.kt | 47 ++++- .../meshtastic/app/map/node/NodeTrackMap.kt | 21 ++- .../kotlin/org/meshtastic/app/MainActivity.kt | 10 +- .../core/ui/util/LocalNodeTrackMapProvider.kt | 18 +- .../feature/node/metrics/BaseMetricChart.kt | 20 +- .../feature/node/metrics/DeviceMetrics.kt | 4 + .../node/metrics/EnvironmentMetrics.kt | 6 + .../feature/node/metrics/MetricsViewModel.kt | 123 ++++++++++--- .../node/metrics/PositionLogComponents.kt | 157 +++++++++------- .../node/metrics/PositionLogScreens.kt | 173 +++++------------- .../feature/node/metrics/PowerMetrics.kt | 8 +- .../feature/node/metrics/SignalMetrics.kt | 11 +- .../node/metrics/MetricsViewModelTest.kt | 2 +- 16 files changed, 398 insertions(+), 251 deletions(-) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt index 04f896d18..3cc0dbaf0 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt @@ -124,20 +124,21 @@ fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () return polyline } -fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List { +fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List { val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation) - val markers = positions.map { - Marker(this).apply { - icon = navIcon - rotation = ((it.ground_track ?: 0) * 1e-5).toFloat() - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - position = GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) - setOnMarkerClickListener { _, _ -> - onClick() - true + val markers = + positions.map { pos -> + Marker(this).apply { + icon = navIcon + rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat() + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7) + setOnMarkerClickListener { _, _ -> + onClick(pos.time) + true + } } } - } overlays.addAll(markers) return markers diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt index 0178a498e..77b595d88 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -26,9 +26,17 @@ import org.meshtastic.proto.Position * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain * [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation * ([NodeTrackOsmMap]). + * + * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. */ @Composable -fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = Modifier) { +fun NodeTrackMap( + destNum: Int, + positions: List, + modifier: Modifier = Modifier, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, +) { val vm = koinViewModel() vm.setDestNum(destNum) NodeTrackOsmMap( @@ -36,5 +44,7 @@ fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = M applicationId = vm.applicationId, mapStyleId = vm.mapStyleId, modifier = modifier, + selectedPositionTime = selectedPositionTime, + onPositionSelected = onPositionSelected, ) } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt index 64d207a6e..b24e57b63 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt @@ -64,6 +64,8 @@ import kotlin.math.roundToInt * minimal [MapControlsOverlay][org.meshtastic.app.map.component.MapControlsOverlay] with a track time filter slider so * users can adjust the time range directly from the map. * + * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. + * * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or * location tracking. It is designed to be embedded inside the position-log adaptive layout. */ @@ -73,6 +75,8 @@ fun NodeTrackOsmMap( applicationId: String, mapStyleId: Int, modifier: Modifier = Modifier, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, mapViewModel: MapViewModel = koinViewModel(), ) { val density = LocalDensity.current @@ -109,7 +113,15 @@ fun NodeTrackOsmMap( map.addCopyright() map.addScaleBarOverlay(density) map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(filteredPositions) {} + map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) } + // Center on selected position + if (selectedPositionTime != null) { + val selected = filteredPositions.find { it.time == selectedPositionTime } + if (selected != null) { + val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D) + map.controller.animateTo(point) + } + } }, ) diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 0418d76b7..125f861cc 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -155,7 +156,12 @@ sealed interface GoogleMapMode { data object Main : GoogleMapMode /** Focused node position track: polyline + gradient markers for historical positions. */ - data class NodeTrack(val focusedNode: Node?, val positions: List) : GoogleMapMode + data class NodeTrack( + val focusedNode: Node?, + val positions: List, + val selectedPositionTime: Int? = null, + val onPositionSelected: ((Int) -> Unit)? = null, + ) : GoogleMapMode /** Traceroute visualization: offset forward/return polylines + hop markers. */ data class Traceroute( @@ -424,6 +430,17 @@ fun MapView( Logger.d { "Error centering track map: ${e.message}" } } } + + // Animate to selected position marker when card is tapped in the list + LaunchedEffect(mode.selectedPositionTime) { + val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect + val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect + try { + cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng())) + } catch (e: IllegalStateException) { + Logger.d { "Error animating to selected position: ${e.message}" } + } + } } if (mode is GoogleMapMode.Traceroute) { @@ -577,6 +594,8 @@ fun MapView( sortedPositions = sortedTrackPositions, displayUnits = displayUnits, myNodeNum = myNodeNum, + selectedPositionTime = mode.selectedPositionTime, + onPositionSelected = mode.onPositionSelected, ) } } @@ -808,17 +827,24 @@ private fun MainMapContent( * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a * [TripOrigin] dot with an info-window on tap. + * + * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and + * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization. */ @OptIn(MapsComposeExperimentalApi::class) @Composable +@Suppress("LongMethod") private fun NodeTrackOverlay( focusedNode: Node, sortedPositions: List, displayUnits: DisplayUnits, myNodeNum: Int?, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, ) { val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite val activeNodeZIndex = if (isHighPriority) 5f else 4f + val selectedColor = MaterialTheme.colorScheme.primary sortedPositions.forEachIndexed { index, position -> key(position.time) { @@ -829,13 +855,23 @@ private fun NodeTrackOverlay( } else { 1f } - val color = Color(focusedNode.colors.second).copy(alpha = alpha) + val isSelected = position.time == selectedPositionTime + val color = + if (isSelected) { + selectedColor + } else { + Color(focusedNode.colors.second).copy(alpha = alpha) + } if (index == sortedPositions.lastIndex) { MarkerComposable( state = markerState, zIndex = activeNodeZIndex, alpha = if (isHighPriority) 1.0f else 0.9f, + onClick = { + onPositionSelected?.invoke(position.time) + false // Allow default info window behavior + }, ) { NodeChip(node = focusedNode) } @@ -844,13 +880,18 @@ private fun NodeTrackOverlay( state = markerState, title = stringResource(Res.string.position), snippet = formatAgo(position.time), - zIndex = 1f + alpha, + zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha, + onClick = { + onPositionSelected?.invoke(position.time) + false // Allow default info window behavior + }, infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) }, ) { Icon( imageVector = MeshtasticIcons.TripOrigin, contentDescription = stringResource(Res.string.track_point), tint = color, + modifier = if (isSelected) Modifier.size(32.dp) else Modifier, ) } } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt index 513957c61..2f7244b97 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -31,11 +31,28 @@ import org.meshtastic.proto.Position * [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode, * which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track * filter). + * + * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. */ @Composable -fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = Modifier) { +fun NodeTrackMap( + destNum: Int, + positions: List, + modifier: Modifier = Modifier, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, +) { val vm = koinViewModel() vm.setDestNum(destNum) val focusedNode by vm.node.collectAsStateWithLifecycle() - MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions)) + MapView( + modifier = modifier, + mode = + GoogleMapMode.NodeTrack( + focusedNode = focusedNode, + positions = positions, + selectedPositionTime = selectedPositionTime, + onPositionSelected = onPositionSelected, + ), + ) } diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 342b845dd..03549c0b3 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -175,8 +175,14 @@ class MainActivity : ComponentActivity() { LocalMapViewProvider provides getMapViewProvider(), LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) }, LocalNodeTrackMapProvider provides - { destNum, positions, modifier -> - org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier) + { destNum, positions, modifier, selectedPositionTime, onPositionSelected -> + org.meshtastic.app.map.node.NodeTrackMap( + destNum, + positions, + modifier, + selectedPositionTime, + onPositionSelected, + ) }, LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(), LocalTracerouteMapProvider provides diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt index 5ac8eca5a..d0901f0f9 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt @@ -27,10 +27,24 @@ import org.meshtastic.proto.Position * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded * inside another screen layout (e.g. the position-log adaptive layout). * + * Supports optional synchronized selection: + * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When + * non-null, the map should visually highlight the corresponding marker and center the camera on it. + * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so + * the host can synchronize the card list. + * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeTrackMapProvider = - compositionLocalOf<@Composable (destNum: Int, positions: List, modifier: Modifier) -> Unit> { - { _, _, _ -> PlaceholderScreen("Position Track Map") } + compositionLocalOf< + @Composable ( + destNum: Int, + positions: List, + modifier: Modifier, + selectedPositionTime: Int?, + onPositionSelected: ((Int) -> Unit)?, + ) -> Unit, + > { + { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index cb96607d9..b8e6f0aae 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -68,12 +69,14 @@ import org.meshtastic.core.resources.collapse_chart import org.meshtastic.core.resources.expand_chart import org.meshtastic.core.resources.info import org.meshtastic.core.resources.logs +import org.meshtastic.core.resources.save import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.BarChart import org.meshtastic.core.ui.icon.Info import org.meshtastic.core.ui.icon.List import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh +import org.meshtastic.core.ui.icon.Save /** * A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point @@ -217,8 +220,10 @@ fun AdaptiveMetricLayout( * A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and chart-to-list * synchronisation. * - * @param extraActions Additional composable actions rendered in the app bar before the expand/collapse toggle (e.g. a + * @param extraActions Additional composable actions rendered in the app bar before the standard buttons (e.g. a * cooldown traceroute button). + * @param onExportCsv When non-null, a Save [IconButton] is rendered in the app bar that invokes this callback. This + * centralises the CSV export affordance so individual screens only need to provide the export logic. */ @Composable @Suppress("LongMethod") @@ -231,13 +236,14 @@ fun BaseMetricScreen( timeProvider: (T) -> Double, infoData: List = emptyList(), onRequestTelemetry: (() -> Unit)? = null, + onExportCsv: (() -> Unit)? = null, extraActions: @Composable () -> Unit = {}, chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit, listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit, controlPart: @Composable () -> Unit = {}, ) { - var displayInfoDialog by remember { mutableStateOf(false) } - var isChartExpanded by remember { mutableStateOf(false) } + var displayInfoDialog by rememberSaveable { mutableStateOf(false) } + var isChartExpanded by rememberSaveable { mutableStateOf(false) } val lazyListState = rememberLazyListState() val vicoScrollState = @@ -259,6 +265,14 @@ fun BaseMetricScreen( onNavigateUp = onNavigateUp, actions = { extraActions() + if (onExportCsv != null && data.isNotEmpty()) { + IconButton(onClick = onExportCsv) { + Icon( + imageVector = MeshtasticIcons.Save, + contentDescription = stringResource(Res.string.save), + ) + } + } IconButton(onClick = { isChartExpanded = !isChartExpanded }) { Icon( imageVector = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index a3fef636f..f3e02818d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -81,6 +81,7 @@ import org.meshtastic.core.ui.theme.GraphColors.Cyan import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.core.ui.theme.GraphColors.Purple +import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry private enum class Device(val color: Color) { @@ -116,6 +117,8 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() val data = state.deviceMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() } + val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDeviceMetricsCSV(uri, data) } + val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } } val hasVoltage = remember(data) { data.any { it.device_metrics?.voltage != null } } val hasChUtil = remember(data) { data.any { it.device_metrics?.channel_utilization != null } } @@ -167,6 +170,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { timeProvider = { it.time.toDouble() }, infoData = infoItems, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.DEVICE) }, + onExportCsv = { exportLauncher("device_metrics.csv", "text/csv") }, controlPart = { TimeFrameSelector( selectedTimeFrame = timeFrame, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 2b47fd5e1..12c604a46 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -71,6 +71,7 @@ import org.meshtastic.core.resources.wind_speed import org.meshtastic.core.ui.component.IaqDisplayMode import org.meshtastic.core.ui.component.IndoorAirQuality import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry @Composable @@ -81,6 +82,10 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() + val exportLauncher = rememberSaveFileLauncher { uri -> + viewModel.saveEnvironmentMetricsCSV(uri, filteredTelemetries) + } + BaseMetricScreen( onNavigateUp = onNavigateUp, telemetryType = TelemetryType.ENVIRONMENT, @@ -90,6 +95,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un timeProvider = { it.time.toDouble() }, infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)), onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }, + onExportCsv = { exportLauncher("environment_metrics.csv", "text/csv") }, controlPart = { TimeFrameSelector( selectedTimeFrame = timeFrame, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 51ef4ef8c..8c6ca9222 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -67,6 +67,7 @@ import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.TimeFrame +import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.Telemetry import kotlin.time.Instant @@ -320,35 +321,111 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel cleared" } } - fun savePositionCSV(uri: MeshtasticUri) { - viewModelScope.launch(dispatchers.main) { - val positions = state.value.positionLogs + // region --- CSV Export --- + + /** + * Shared CSV export helper. Writes [header] then iterates [rows], converting each item to a CSV line via + * [rowMapper]. The mapper returns only the data columns; date and time columns are prepended automatically from the + * epoch-seconds timestamp extracted by [epochSeconds]. + */ + private fun exportCsv( + uri: MeshtasticUri, + header: String, + rows: List, + epochSeconds: (T) -> Long, + rowMapper: (T) -> String, + ) { + viewModelScope.launch(dispatchers.io) { fileService.write(uri) { sink -> - sink.writeUtf8( - "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n", - ) - - positions.forEach { position -> - val localDateTime = - Instant.fromEpochSeconds(position.time.toLong()) - .toLocalDateTime(TimeZone.currentSystemDefault()) - val rxDateTime = "\"${localDateTime.date}\",\"${localDateTime.time}\"" - - val latitude = (position.latitude_i ?: 0) * GeoConstants.DEG_D - val longitude = (position.longitude_i ?: 0) * GeoConstants.DEG_D - val altitude = position.altitude - val satsInView = position.sats_in_view - val speed = position.ground_speed - val heading = formatString("%.2f", (position.ground_track ?: 0) * GeoConstants.HEADING_DEG) - - sink.writeUtf8( - "$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n", - ) + sink.writeUtf8(header) + rows.forEach { item -> + val dt = + Instant.fromEpochSeconds(epochSeconds(item)).toLocalDateTime(TimeZone.currentSystemDefault()) + sink.writeUtf8("\"${dt.date}\",\"${dt.time}\",${rowMapper(item)}\n") } } } } + fun savePositionCSV(uri: MeshtasticUri, data: List) { + exportCsv( + uri = uri, + header = + "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\"," + "\"satsInView\",\"speed\",\"heading\"\n", + rows = data, + epochSeconds = { it.time.toLong() }, + ) { pos -> + val lat = (pos.latitude_i ?: 0) * GeoConstants.DEG_D + val lon = (pos.longitude_i ?: 0) * GeoConstants.DEG_D + val heading = formatString("%.2f", (pos.ground_track ?: 0) * GeoConstants.HEADING_DEG) + "\"$lat\",\"$lon\",\"${pos.altitude}\",\"${pos.sats_in_view}\",\"${pos.ground_speed}\",\"$heading\"" + } + } + + fun saveDeviceMetricsCSV(uri: MeshtasticUri, data: List) { + exportCsv( + uri = uri, + header = + "\"date\",\"time\",\"batteryLevel\",\"voltage\",\"channelUtilization\"," + + "\"airUtilTx\",\"uptimeSeconds\"\n", + rows = data, + epochSeconds = { it.time.toLong() }, + ) { t -> + val dm = t.device_metrics + "\"${dm?.battery_level ?: ""}\",\"${dm?.voltage ?: ""}\"," + + "\"${dm?.channel_utilization ?: ""}\",\"${dm?.air_util_tx ?: ""}\"," + + "\"${dm?.uptime_seconds ?: ""}\"" + } + } + + fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List) { + exportCsv( + uri = uri, + header = + "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," + + "\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," + + "\"soilMoisture\"\n", + rows = data, + epochSeconds = { it.time.toLong() }, + ) { t -> + val em = t.environment_metrics + "\"${em?.temperature ?: ""}\",\"${em?.relative_humidity ?: ""}\"," + + "\"${em?.barometric_pressure ?: ""}\",\"${em?.gas_resistance ?: ""}\"," + + "\"${em?.iaq ?: ""}\",\"${em?.wind_speed ?: ""}\"," + + "\"${em?.wind_direction ?: ""}\",\"${em?.soil_temperature ?: ""}\"," + + "\"${em?.soil_moisture ?: ""}\"" + } + } + + fun saveSignalMetricsCSV(uri: MeshtasticUri, data: List) { + exportCsv( + uri = uri, + header = "\"date\",\"time\",\"rssi\",\"snr\"\n", + rows = data, + epochSeconds = { it.rx_time.toLong() }, + ) { p -> + "\"${p.rx_rssi}\",\"${p.rx_snr}\"" + } + } + + fun savePowerMetricsCSV(uri: MeshtasticUri, data: List) { + exportCsv( + uri = uri, + header = + "\"date\",\"time\",\"ch1Voltage\",\"ch1Current\",\"ch2Voltage\",\"ch2Current\"," + + "\"ch3Voltage\",\"ch3Current\"\n", + rows = data, + epochSeconds = { it.time.toLong() }, + ) { t -> + val pm = t.power_metrics + "\"${pm?.ch1_voltage ?: ""}\",\"${pm?.ch1_current ?: ""}\"," + + "\"${pm?.ch2_voltage ?: ""}\",\"${pm?.ch2_current ?: ""}\"," + + "\"${pm?.ch3_voltage ?: ""}\",\"${pm?.ch3_current ?: ""}\"" + } + } + + // endregion + @Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount") fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? { try { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt index 62ab7a0d4..e2f95f04b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt @@ -14,27 +14,32 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package org.meshtastic.feature.node.metrics import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +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.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.util.GeoConstants.DEG_D import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.toString import org.meshtastic.core.resources.Res @@ -43,69 +48,95 @@ import org.meshtastic.core.resources.heading import org.meshtastic.core.resources.latitude import org.meshtastic.core.resources.longitude import org.meshtastic.core.resources.sats -import org.meshtastic.core.resources.speed import org.meshtastic.core.resources.speed_kmh -import org.meshtastic.core.resources.timestamp -import org.meshtastic.core.ui.util.formatPositionTime +import org.meshtastic.core.ui.theme.GraphColors import org.meshtastic.proto.Config import org.meshtastic.proto.Position +/** + * A [SelectableMetricCard]-based position item that matches the visual style of [DeviceMetricsCard], + * [SignalMetricsCard], and other metric cards. Replaces the previous table-row layout with a card that shows timestamp, + * coordinates, satellites, altitude, speed, and heading. + */ @Composable -private fun RowScope.PositionText(text: String, weight: Float) { - Text( - text = text, - modifier = Modifier.weight(weight), - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) -} - -private const val WEIGHT_10 = .10f -private const val WEIGHT_15 = .15f -private const val WEIGHT_20 = .20f -private const val WEIGHT_40 = .40f - -@Composable -fun PositionLogHeader(compactWidth: Boolean) { - Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) { - PositionText(stringResource(Res.string.latitude), WEIGHT_20) - PositionText(stringResource(Res.string.longitude), WEIGHT_20) - PositionText(stringResource(Res.string.sats), WEIGHT_10) - PositionText(stringResource(Res.string.alt), WEIGHT_15) - if (!compactWidth) { - PositionText(stringResource(Res.string.speed), WEIGHT_15) - PositionText(stringResource(Res.string.heading), WEIGHT_15) - } - PositionText(stringResource(Res.string.timestamp), WEIGHT_40) - } -} - -@Composable -fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - PositionText(formatString("%.5f", (position.latitude_i ?: 0) * DEG_D), WEIGHT_20) - PositionText(formatString("%.5f", (position.longitude_i ?: 0) * DEG_D), WEIGHT_20) - PositionText(position.sats_in_view.toString(), WEIGHT_10) - PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15) - if (!compactWidth) { - PositionText(stringResource(Res.string.speed_kmh, position.ground_speed ?: 0), WEIGHT_15) - PositionText(formatString("%.0f°", (position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15) - } - PositionText(position.formatPositionTime(), WEIGHT_40) - } -} - -@Composable -fun ColumnScope.PositionList( - compactWidth: Boolean, - positions: List, +@Suppress("LongMethod") +fun PositionCard( + position: Position, displayUnits: Config.DisplayConfig.DisplayUnits, + isSelected: Boolean, + onClick: () -> Unit, ) { - LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { - items(positions) { position -> PositionItem(compactWidth, position, displayUnits) } + val time = position.time.toLong() * MS_PER_SEC + val latitude = formatString("%.5f", (position.latitude_i ?: 0) * DEG_D) + val longitude = formatString("%.5f", (position.longitude_i ?: 0) * DEG_D) + + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + /* Timestamp */ + Text( + text = DateFormatter.formatDateTime(time), + style = MaterialTheme.typography.titleMediumEmphasized, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + /* Coordinates */ + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + MetricValueRow(color = GraphColors.Blue, text = "${stringResource(Res.string.latitude)}: $latitude") + Spacer(Modifier.width(12.dp)) + MetricValueRow( + color = GraphColors.Green, + text = "${stringResource(Res.string.longitude)}: $longitude", + ) + } + Text( + text = "${stringResource(Res.string.sats)}: ${position.sats_in_view}", + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + /* Alt, Speed, Heading */ + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + MetricValueRow( + color = GraphColors.Purple, + text = + "${stringResource(Res.string.alt)}: ${ + (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits) + }", + ) + if (position.ground_speed != null && position.ground_speed != 0) { + Spacer(Modifier.width(12.dp)) + MetricValueRow( + color = GraphColors.Gold, + text = stringResource(Res.string.speed_kmh, position.ground_speed ?: 0), + ) + } + } + if (position.ground_track != null && position.ground_track != 0) { + Text( + text = + "${stringResource(Res.string.heading)}: ${ + formatString("%.0f", (position.ground_track ?: 0) * HEADING_DEG) + }\u00B0", + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } + } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index cb7d147d2..e414ea26d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -16,158 +16,69 @@ */ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.clear -import org.meshtastic.core.resources.collapse_chart -import org.meshtastic.core.resources.expand_chart -import org.meshtastic.core.resources.logs import org.meshtastic.core.resources.position_log -import org.meshtastic.core.resources.save -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.icon.BarChart import org.meshtastic.core.ui.icon.Delete -import org.meshtastic.core.ui.icon.List import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.icon.Save import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider +import org.meshtastic.core.ui.util.rememberSaveFileLauncher -@Composable -private fun ActionButtons( - clearButtonEnabled: Boolean, - onClear: () -> Unit, - saveButtonEnabled: Boolean, - onSave: () -> Unit, - modifier: Modifier = Modifier, -) { - FlowRow( - modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = onClear, - enabled = clearButtonEnabled, - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { - Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear)) - Spacer(Modifier.width(8.dp)) - Text(text = stringResource(Res.string.clear)) - } - - OutlinedButton(modifier = Modifier.weight(1f), onClick = onSave, enabled = saveButtonEnabled) { - Icon(imageVector = MeshtasticIcons.Save, contentDescription = stringResource(Res.string.save)) - Spacer(Modifier.width(8.dp)) - Text(text = stringResource(Res.string.save)) - } - } -} - -@Suppress("LongMethod") @Composable fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() + val positions = state.positionLogs - val exportPositionLauncher = - org.meshtastic.core.ui.util.rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri) } - - var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) } - var isMapExpanded by remember { mutableStateOf(false) } + val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) } val trackMap = LocalNodeTrackMapProvider.current val destNum = state.node?.num ?: 0 - Scaffold( - topBar = { - MainAppBar( - title = state.node?.user?.long_name ?: "", - subtitle = - stringResource(Res.string.position_log) + - " (${state.positionLogs.size} ${stringResource(Res.string.logs)})", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = { - IconButton(onClick = { isMapExpanded = !isMapExpanded }) { - Icon( - imageVector = if (isMapExpanded) MeshtasticIcons.List else MeshtasticIcons.BarChart, - contentDescription = - stringResource( - if (isMapExpanded) Res.string.collapse_chart else Res.string.expand_chart, - ), - ) - } - if (!state.isLocal) { - IconButton(onClick = { viewModel.requestPosition() }) { - Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) - } - } - }, - onClickChip = {}, - ) + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = null, + titleRes = Res.string.position_log, + nodeName = state.node?.user?.long_name ?: "", + data = positions, + timeProvider = { it.time.toDouble() }, + onExportCsv = { exportPositionLauncher("position.csv", "text/csv") }, + extraActions = { + if (positions.isNotEmpty()) { + IconButton(onClick = { viewModel.clearPosition() }) { + Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear)) + } + } + if (!state.isLocal) { + IconButton(onClick = { viewModel.requestPosition() }) { + Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) + } + } }, - ) { innerPadding -> - Column(modifier = Modifier.padding(innerPadding)) { - AdaptiveMetricLayout( - isChartExpanded = isMapExpanded, - chartPart = { modifier -> trackMap(destNum, state.positionLogs, modifier) }, - listPart = { modifier -> - BoxWithConstraints(modifier = modifier) { - val compactWidth = maxWidth < 600.dp - Column { - val textStyle = - if (compactWidth) { - MaterialTheme.typography.bodySmall - } else { - LocalTextStyle.current - } - CompositionLocalProvider(LocalTextStyle provides textStyle) { - PositionLogHeader(compactWidth) - PositionList(compactWidth, state.positionLogs, state.displayUnits) - } - - ActionButtons( - clearButtonEnabled = clearButtonEnabled, - onClear = { - clearButtonEnabled = false - viewModel.clearPosition() - }, - saveButtonEnabled = state.hasPositionLogs(), - onSave = { exportPositionLauncher("position.csv", "text/csv") }, - ) - } - } - }, - ) - } - } + chartPart = { modifier, selectedX, _, onPointSelected -> + val selectedTime = selectedX?.toInt() + trackMap(destNum, positions, modifier, selectedTime) { time -> onPointSelected(time.toDouble()) } + }, + listPart = { modifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(positions) { _, position -> + PositionCard( + position = position, + displayUnits = state.displayUnits, + isSelected = position.time.toDouble() == selectedX, + onClick = { onCardClick(position.time.toDouble()) }, + ) + } + } + }, + ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index ebfae8407..e2064fd5f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -72,6 +73,7 @@ import org.meshtastic.core.resources.power_metrics_log import org.meshtastic.core.resources.voltage import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue +import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry private enum class PowerMetric(val color: Color) { @@ -103,13 +105,16 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() val data = state.powerMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() } + + val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.savePowerMetricsCSV(uri, data) } + val availableChannels = remember(data) { PowerChannel.entries.filter { channel -> data.any { !retrieveVoltage(channel, it).isNaN() || !retrieveCurrent(channel, it).isNaN() } } } - var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) } + var selectedChannel by rememberSaveable { mutableStateOf(PowerChannel.ONE) } BaseMetricScreen( onNavigateUp = onNavigateUp, @@ -119,6 +124,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { data = data, timeProvider = { it.time.toDouble() }, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) }, + onExportCsv = { exportLauncher("power_metrics.csv", "text/csv") }, controlPart = { Column { TimeFrameSelector( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 376b55289..ca6fd2d61 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -55,13 +55,12 @@ import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.rssi -import org.meshtastic.core.resources.rssi_definition import org.meshtastic.core.resources.signal_quality import org.meshtastic.core.resources.snr -import org.meshtastic.core.resources.snr_definition import org.meshtastic.core.ui.component.LoraSignalIndicator import org.meshtastic.core.ui.theme.GraphColors.Blue import org.meshtastic.core.ui.theme.GraphColors.Green +import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.MeshPacket private enum class SignalMetric(val color: Color) { @@ -83,6 +82,8 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() val data = state.signalMetrics.filter { it.rx_time.toLong() >= timeFrame.timeThreshold() } + val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveSignalMetricsCSV(uri, data) } + BaseMetricScreen( onNavigateUp = onNavigateUp, telemetryType = TelemetryType.LOCAL_STATS, @@ -91,11 +92,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { data = data, timeProvider = { it.rx_time.toDouble() }, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }, - infoData = - listOf( - InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color), - InfoDialogData(Res.string.rssi, Res.string.rssi_definition, SignalMetric.RSSI.color), - ), + onExportCsv = { exportLauncher("signal_metrics.csv", "text/csv") }, controlPart = { TimeFrameSelector( selectedTimeFrame = timeFrame, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index 34e411af0..961a34dd6 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -211,7 +211,7 @@ class MetricsViewModelTest { awaitItem() // with position val uri = MeshtasticUri("content://test") - vm.savePositionCSV(uri) + vm.savePositionCSV(uri, listOf(testPosition)) runCurrent() verifySuspend { fileService.write(uri, any()) } From 3794c79daee9d2024531795b0af9221ef17880c6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:10:03 -0500 Subject: [PATCH 003/114] refactor: adopt M3 Expressive components from material3 1.11.0-alpha06 (#5063) --- .../kotlin/org/meshtastic/app/map/MapView.kt | 4 +- .../app/map/node/NodeTrackOsmMap.kt | 6 +-- .../kotlin/org/meshtastic/app/map/MapView.kt | 4 +- .../app/map/component/CustomMapLayersSheet.kt | 6 ++- .../ui/component/EditPasswordPreference.kt | 8 ++-- .../core/ui/component/PreferenceFooter.kt | 15 +++++-- .../core/ui/qr/ScannedQrCodeDialog.kt | 21 ++++++++-- .../ui/components/ConnectingDeviceInfo.kt | 12 ++++-- .../feature/firmware/FirmwareUpdateScreen.kt | 40 +++++++++++++++---- .../feature}/map/component/MapButton.kt | 2 +- .../map/component/MapControlsOverlay.kt | 18 ++++++--- .../feature/node/component/NodeItem.kt | 3 -- .../feature/node/metrics/BaseMetricChart.kt | 3 +- .../feature/node/metrics/DeviceMetrics.kt | 2 - .../node/metrics/EnvironmentMetrics.kt | 2 - .../feature/node/metrics/HostMetricsLog.kt | 2 - .../node/metrics/MetricLogComponents.kt | 3 -- .../feature/node/metrics/PaxMetrics.kt | 3 -- .../feature/node/metrics/PowerMetrics.kt | 2 - .../feature/node/metrics/SignalMetrics.kt | 3 -- .../feature/node/metrics/TracerouteLog.kt | 2 - .../feature/settings/debugging/Debug.kt | 3 +- .../radio/component/NetworkConfigItemList.kt | 13 +++++- .../radio/component/NodeActionButton.kt | 17 ++++++-- .../wifiprovision/ui/WifiProvisionScreen.kt | 3 +- 25 files changed, 128 insertions(+), 69 deletions(-) rename {app/src/main/kotlin/org/meshtastic/app => feature/map/src/commonMain/kotlin/org/meshtastic/feature}/map/component/MapButton.kt (97%) rename {app/src/main/kotlin/org/meshtastic/app => feature/map/src/commonMain/kotlin/org/meshtastic/feature}/map/component/MapControlsOverlay.kt (87%) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 54935b422..657f7ab74 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -77,8 +77,6 @@ import org.meshtastic.app.map.cluster.RadiusMarkerClusterer import org.meshtastic.app.map.component.CacheLayout import org.meshtastic.app.map.component.DownloadButton import org.meshtastic.app.map.component.EditWaypointDialog -import org.meshtastic.app.map.component.MapButton -import org.meshtastic.app.map.component.MapControlsOverlay import org.meshtastic.app.map.model.CustomTileSource import org.meshtastic.app.map.model.MarkerWithLabel import org.meshtastic.core.common.gpsDisabled @@ -130,6 +128,8 @@ import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.LastHeardFilter +import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Waypoint import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt index b24e57b63..a6aec4c2d 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt @@ -42,7 +42,6 @@ import org.meshtastic.app.map.addCopyright import org.meshtastic.app.map.addPolyline import org.meshtastic.app.map.addPositionMarkers import org.meshtastic.app.map.addScaleBarOverlay -import org.meshtastic.app.map.component.MapControlsOverlay import org.meshtastic.app.map.model.CustomTileSource import org.meshtastic.app.map.rememberMapViewWithLifecycle import org.meshtastic.core.common.util.nowSeconds @@ -50,6 +49,7 @@ import org.meshtastic.core.model.util.GeoConstants.DEG_D import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.last_heard_filter_label import org.meshtastic.feature.map.LastHeardFilter +import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.proto.Position import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint @@ -61,8 +61,8 @@ import kotlin.math.roundToInt * * Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter] * from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a - * minimal [MapControlsOverlay][org.meshtastic.app.map.component.MapControlsOverlay] with a track time filter slider so - * users can adjust the time range directly from the map. + * minimal [MapControlsOverlay][org.meshtastic.feature.map.component.MapControlsOverlay] with a track time filter slider + * so users can adjust the time range directly from the map. * * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. * diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 125f861cc..c8f2f3fee 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -97,8 +97,6 @@ import org.meshtastic.app.map.component.ClusterItemsListDialog import org.meshtastic.app.map.component.CustomMapLayersSheet import org.meshtastic.app.map.component.CustomTileProviderManagerSheet import org.meshtastic.app.map.component.EditWaypointDialog -import org.meshtastic.app.map.component.MapButton -import org.meshtastic.app.map.component.MapControlsOverlay import org.meshtastic.app.map.component.MapFilterDropdown import org.meshtastic.app.map.component.MapTypeDropdown import org.meshtastic.app.map.component.NodeClusterMarkers @@ -137,6 +135,8 @@ import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.LastHeardFilter +import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.feature.map.tracerouteNodeSelection import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Position diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt index fb5f682ed..fd9272579 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -125,7 +126,10 @@ fun CustomMapLayersSheet( } } } - IconButton(onClick = { onToggleVisibility(layer.id) }) { + IconToggleButton( + checked = layer.isVisible, + onCheckedChange = { onToggleVisibility(layer.id) }, + ) { Icon( imageVector = if (layer.isVisible) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt index 681952e61..2dce97aa5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt @@ -19,7 +19,7 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -36,6 +36,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.hide_password import org.meshtastic.core.resources.show_password import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Visibility import org.meshtastic.core.ui.icon.VisibilityOff @Composable @@ -63,10 +64,9 @@ fun EditPasswordPreference( onFocusChanged = {}, visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) { + IconToggleButton(checked = isPasswordVisible, onCheckedChange = { isPasswordVisible = it }) { Icon( - imageVector = - if (isPasswordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.VisibilityOff, + imageVector = if (isPasswordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, contentDescription = if (isPasswordVisible) { stringResource(Res.string.hide_password) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt index 37e354d32..6bf0065bf 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -43,22 +44,28 @@ fun PreferenceFooter( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val mediumHeight = ButtonDefaults.MediumContainerHeight if (negativeText != null) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) ElevatedButton( - modifier = Modifier.height(48.dp).weight(1f), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.height(mediumHeight).weight(1f), colors = ButtonDefaults.filledTonalButtonColors(), onClick = onNegativeClicked, ) { - Text(text = negativeText) + Text(text = negativeText, style = ButtonDefaults.textStyleFor(mediumHeight)) } } if (positiveText != null) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) ElevatedButton( - modifier = Modifier.height(48.dp).weight(1f), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.height(mediumHeight).weight(1f), colors = ButtonDefaults.buttonColors(), onClick = { if (enabled) onPositiveClicked() }, ) { - Text(text = positiveText) + Text(text = positiveText, style = ButtonDefaults.textStyleFor(mediumHeight)) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index 632c8abb4..d5f4e31ec 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -240,21 +241,33 @@ fun ScannedQrCodeDialog( val unselectedColors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val mediumHeight = ButtonDefaults.MediumContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) OutlinedButton( onClick = { shouldReplace = false }, - modifier = Modifier.height(48.dp).weight(1f), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.height(mediumHeight).weight(1f), colors = if (!shouldReplace) selectedColors else unselectedColors, ) { - Text(text = stringResource(Res.string.add)) + Text( + text = stringResource(Res.string.add), + style = ButtonDefaults.textStyleFor(mediumHeight), + ) } + @OptIn(ExperimentalMaterial3ExpressiveApi::class) OutlinedButton( onClick = { shouldReplace = true }, - modifier = Modifier.height(48.dp).weight(1f), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.height(mediumHeight).weight(1f), enabled = incoming.lora_config != null, colors = if (shouldReplace) selectedColors else unselectedColors, ) { - Text(text = stringResource(Res.string.replace)) + Text( + text = stringResource(Res.string.replace), + style = ButtonDefaults.textStyleFor(mediumHeight), + ) } } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt index 9907e01c0..9c86a17bf 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -74,17 +75,20 @@ fun ConnectingDeviceInfo( } } + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val largeHeight = ButtonDefaults.LargeContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( - modifier = Modifier.fillMaxWidth().height(56.dp), - shape = MaterialTheme.shapes.medium, + onClick = onClickDisconnect, + shapes = ButtonDefaults.shapesFor(largeHeight), + modifier = Modifier.fillMaxWidth().height(largeHeight), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.StatusRed, contentColor = Color.White, ), - onClick = onClickDisconnect, ) { - Text(stringResource(Res.string.disconnect), style = MaterialTheme.typography.titleMedium) + Text(stringResource(Res.string.disconnect), style = ButtonDefaults.textStyleFor(largeHeight)) } } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index 0a051fa9c..eee6637af 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -35,15 +35,18 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -381,24 +384,35 @@ private fun ReadyState( Spacer(Modifier.height(16.dp)) if (selectedReleaseType == FirmwareReleaseType.LOCAL) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val largeHeight = ButtonDefaults.LargeContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) showDisclaimer = true }, - modifier = Modifier.fillMaxWidth().height(56.dp), + shapes = ButtonDefaults.shapesFor(largeHeight), + modifier = Modifier.fillMaxWidth().height(largeHeight), ) { Icon(MeshtasticIcons.Folder, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.firmware_update_select_file)) + Text( + stringResource(Res.string.firmware_update_select_file), + style = ButtonDefaults.textStyleFor(largeHeight), + ) } } else if (state.release != null) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val largeHeight = ButtonDefaults.LargeContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) showDisclaimer = true }, - modifier = Modifier.fillMaxWidth().height(56.dp), + shapes = ButtonDefaults.shapesFor(largeHeight), + modifier = Modifier.fillMaxWidth().height(largeHeight), ) { Icon( imageVector = @@ -416,6 +430,7 @@ private fun ReadyState( resource = Res.string.firmware_update_method_detail, stringResource(state.updateMethod.description), ), + style = ButtonDefaults.textStyleFor(largeHeight), ) } Spacer(Modifier.height(24.dp)) @@ -680,7 +695,8 @@ private fun ProgressContent( tint = MaterialTheme.colorScheme.primary, ) } else { - CircularProgressIndicator( + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + CircularWavyProgressIndicator( progress = { if (isUpdating) progressState.progress else 1f }, modifier = Modifier.size(64.dp), ) @@ -708,7 +724,8 @@ private fun ProgressContent( Spacer(Modifier.height(12.dp)) if (isDownloading || isUpdating) { - LinearProgressIndicator( + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + LinearWavyProgressIndicator( progress = { progressState.progress }, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), ) @@ -850,8 +867,15 @@ private fun SuccessState(onDone: () -> Unit) { textAlign = TextAlign.Center, ) Spacer(Modifier.height(32.dp)) - Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text(stringResource(Res.string.firmware_update_done)) + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val largeHeight = ButtonDefaults.LargeContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + Button( + onClick = onDone, + shapes = ButtonDefaults.shapesFor(largeHeight), + modifier = Modifier.fillMaxWidth().height(largeHeight), + ) { + Text(stringResource(Res.string.firmware_update_done), style = ButtonDefaults.textStyleFor(largeHeight)) } } } diff --git a/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt similarity index 97% rename from app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 997d7d08b..a8bce5529 100644 --- a/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.map.component +package org.meshtastic.feature.map.component import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon diff --git a/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt similarity index 87% rename from app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index 74f08e07f..431354e6d 100644 --- a/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -14,13 +14,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.map.component +package org.meshtastic.feature.map.component import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingToolbarDefaults +import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -41,8 +43,9 @@ import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.StatusColors.StatusRed /** - * Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location - * tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh). + * Shared map controls overlay using [HorizontalFloatingToolbar] for Material 3 Expressive styling. Provides compass, + * filter button, location tracking button, and optional slots for flavor-specific content (map type selector, layers, + * refresh). * * @param onToggleFilterMenu Callback to open/close the filter dropdown. * @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a @@ -54,6 +57,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed * @param isRefreshing Whether a refresh is currently in progress. * @param onRefresh Callback when the refresh button is clicked. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongParameterList") @Composable fun MapControlsOverlay( @@ -71,7 +75,11 @@ fun MapControlsOverlay( isRefreshing: Boolean = false, onRefresh: () -> Unit = {}, ) { - Row(modifier = modifier) { + HorizontalFloatingToolbar( + expanded = true, + modifier = modifier, + colors = FloatingToolbarDefaults.standardFloatingToolbarColors(), + ) { // Compass CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index cbf99e9ca..514be15e7 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -96,7 +95,6 @@ private const val ACTIVE_ALPHA = 0.5f private const val INACTIVE_ALPHA = 0.2f private const val GRID_COLUMNS = 3 -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @Suppress("LongMethod") fun NodeItem( @@ -391,7 +389,6 @@ private fun MetricsGrid(items: List<@Composable () -> Unit>) { } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun NodeItemHeader( thatNode: Node, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index b8e6f0aae..8f65bf6d8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -273,7 +274,7 @@ fun BaseMetricScreen( ) } } - IconButton(onClick = { isChartExpanded = !isChartExpanded }) { + IconToggleButton(checked = isChartExpanded, onCheckedChange = { isChartExpanded = it }) { Icon( imageVector = if (isChartExpanded) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index f3e02818d..5725da604 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -15,7 +15,6 @@ * along with this program. If not, see . */ @file:Suppress("MagicNumber") -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics @@ -31,7 +30,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 12c604a46..4f9e88d47 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -15,7 +15,6 @@ * along with this program. If not, see . */ @file:Suppress("TooManyFunctions") -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics @@ -30,7 +29,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index f22710ef5..2cbf008e1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults @@ -155,7 +154,6 @@ private fun HostMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: } /** Card body showing timestamp, load averages with progress bars, memory, disk, and uptime. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun HostMetricsCardContent(time: String, hostMetrics: org.meshtastic.proto.HostMetrics?) { Column(modifier = Modifier.padding(12.dp)) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt index 653293835..92e929056 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt @@ -14,8 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - package org.meshtastic.feature.node.metrics import androidx.compose.foundation.BorderStroke @@ -35,7 +33,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index cad2b63b1..598cd5ca9 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -14,8 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - package org.meshtastic.feature.node.metrics import androidx.compose.foundation.layout.Column @@ -28,7 +26,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index e2064fd5f..c815f6622 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -15,7 +15,6 @@ * along with this program. If not, see . */ @file:Suppress("MagicNumber") -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics @@ -31,7 +30,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index ca6fd2d61..e8b184427 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -14,8 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - package org.meshtastic.feature.node.metrics import androidx.compose.foundation.layout.Arrangement @@ -31,7 +29,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index bf5846e9f..caf3e1938 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -242,7 +241,6 @@ private fun TracerouteCard( /** Card body showing timestamp, route summary text/icon, and metric indicators. */ @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun TracerouteCardContent(time: String, summaryText: String, icon: ImageVector, point: TraceroutePoint) { Column(modifier = Modifier.padding(12.dp)) { Row( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index 3fab5b624..dba15e1a4 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -138,7 +139,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) { canNavigateUp = true, onNavigateUp = onNavigateUp, actions = { - IconButton(onClick = { showSettings = !showSettings }) { + IconToggleButton(checked = showSettings, onCheckedChange = { showSettings = it }) { Icon(imageVector = MeshtasticIcons.Settings, contentDescription = null) } DebugMenuActions(deleteLogs = { viewModel.requestDeleteAllLogs() }) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt index b9796aba5..584f8eedc 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -220,12 +222,19 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onO onValueChanged = { formState.value = formState.value.copy(wifi_psk = it) }, ) HorizontalDivider() + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val mediumHeight = ButtonDefaults.MediumContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( onClick = { barcodeScanner.startScan() }, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).height(48.dp), + shapes = ButtonDefaults.shapesFor(mediumHeight), + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).height(mediumHeight), enabled = state.connected, ) { - Text(text = stringResource(Res.string.wifi_qr_code_scan)) + Text( + text = stringResource(Res.string.wifi_qr_code_scan), + style = ButtonDefaults.textStyleFor(mediumHeight), + ) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NodeActionButton.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NodeActionButton.kt index fe9675e6d..fa6d9a8fb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NodeActionButton.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NodeActionButton.kt @@ -24,9 +24,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -37,14 +38,22 @@ import androidx.compose.ui.unit.dp @Composable fun NodeActionButton( - modifier: Modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).height(48.dp), + modifier: Modifier = Modifier, title: String, enabled: Boolean, icon: ImageVector? = null, iconTint: Color? = null, onClick: () -> Unit, ) { - Button(onClick = { onClick() }, enabled = enabled, modifier = modifier) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + val mediumHeight = ButtonDefaults.MediumContainerHeight + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + Button( + onClick = { onClick() }, + shapes = ButtonDefaults.shapesFor(mediumHeight), + enabled = enabled, + modifier = modifier.then(Modifier.fillMaxWidth().padding(vertical = 4.dp).height(mediumHeight)), + ) { Row(verticalAlignment = Alignment.CenterVertically) { if (icon != null) { Icon( @@ -55,7 +64,7 @@ fun NodeActionButton( ) Spacer(modifier = Modifier.width(8.dp)) } - Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(text = title, style = ButtonDefaults.textStyleFor(mediumHeight), modifier = Modifier.weight(1f)) } } } diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt index 20b54825e..785654c71 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -414,7 +415,7 @@ internal fun ConnectedContent( singleLine = true, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { + IconToggleButton(checked = passwordVisible, onCheckedChange = { passwordVisible = it }) { Icon( imageVector = if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, From 6b77658cb198e76c52dd519d134a25df291e5375 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:48:09 -0500 Subject: [PATCH 004/114] ci: remove mesh_service_example from CI checks and Codecov (#5066) --- .github/workflows/pull-request.yml | 4 ++-- .github/workflows/reusable-check.yml | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6649dbc84..0d2b67b36 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -39,7 +39,6 @@ jobs: - 'desktop/**' - 'core/**' - 'feature/**' - - 'mesh_service_example/**' # Shared build infrastructure - 'build-logic/**' - 'config/**' @@ -75,7 +74,8 @@ jobs: } allowed_extra_roots = {'baselineprofile'} - expected_roots = module_roots | allowed_extra_roots + excluded_roots = {'mesh_service_example'} + expected_roots = (module_roots | allowed_extra_roots) - excluded_roots filter_paths = { path.split('/')[0] diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 75557fe00..1ad33c4e8 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -97,7 +97,7 @@ jobs: - name: Lint, Analysis & KMP Smoke Compile if: inputs.run_lint == true - run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug kmpSmokeCompile -Pci=true --continue --scan + run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan - name: KMP Smoke Compile (lint skipped) if: inputs.run_lint == false @@ -176,14 +176,12 @@ jobs: :desktop:test :core:barcode:testFdroidDebugUnitTest :core:barcode:testGoogleDebugUnitTest - :mesh_service_example:test kover: >- :app:koverXmlReportFdroidDebug :app:koverXmlReportGoogleDebug :core:barcode:koverXmlReportFdroidDebug :core:barcode:koverXmlReportGoogleDebug :desktop:koverXmlReport - :mesh_service_example:koverXmlReportDebug steps: - name: Checkout code @@ -287,7 +285,6 @@ jobs: tasks=( "app:assembleFdroidDebug" "app:assembleGoogleDebug" - "mesh_service_example:assembleDebug" ) if [[ "${{ inputs.run_instrumented_tests }}" == "true" ]]; then From 1f88a26d5189ed959a96e250657ee69d134acc3c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:50:32 -0500 Subject: [PATCH 005/114] feat(desktop): align versioning with Android, build runnable distributions in CI (#5064) --- .github/workflows/reusable-check.yml | 14 +-- AGENTS.md | 6 +- desktop/build.gradle.kts | 90 +++++++++++++++++-- .../desktop/di/DesktopPlatformModule.kt | 15 ++-- 4 files changed, 102 insertions(+), 23 deletions(-) diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 1ad33c4e8..c67cc280a 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -357,12 +357,16 @@ jobs: # ── Desktop Build ─────────────────────────────────────────────────── build-desktop: - name: Build Desktop Debug - runs-on: ubuntu-24.04 + name: Build Desktop Debug (${{ matrix.os }}) + runs-on: ${{ matrix.os }} permissions: contents: read timeout-minutes: 60 needs: lint-check + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm] env: VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} @@ -380,12 +384,12 @@ jobs: cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - name: Build Desktop - run: ./gradlew :desktop:packageDistributionForCurrentOS -Pci=true --scan + run: ./gradlew :desktop:createDistributable -Pci=true --scan - name: Upload Desktop artifact if: ${{ inputs.upload_artifacts }} uses: actions/upload-artifact@v7 with: - name: desktop-app - path: desktop/build/compose/binaries/main/app/Meshtastic/bin/* + name: desktop-app-${{ runner.os }}-${{ runner.arch }} + path: desktop/build/compose/binaries/main/app/ retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index ed603d08a..b8fe03945 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. | | `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Scans for provisioning devices, lists available networks, applies credentials. Uses `core:ble` Kable abstractions. | | `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi via Kable/Ktor), native Nordic Secure DFU protocol (pure KMP, no Nordic library), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | -| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | +| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. Versioning mirrors Android via `config.properties` + `GitVersionValueSource`; a `generateDesktopBuildConfig` task produces `DesktopBuildConfig.kt` at build time. | | `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app showing `core:api` service integration. Do not add code here. See `core/api/README.md` for the current integration guide. | ## 3. Development Guidelines & Coding Standards @@ -168,7 +168,7 @@ Always run commands in the following order to ensure reliability. Do not attempt Each shard generates its own Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. Downstream jobs (test-shards, android-check, build-desktop) use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. 3. **`android-check`** — Builds APKs and runs instrumented tests (depends on `lint-check`). - 4. **`build-desktop`** — Desktop packaging (depends on `lint-check`). + 4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds runnable desktop distributions via `createDistributable` (depends on `lint-check`). The Kotlin/Native host-platform warning on `linux-aarch64` is non-fatal; only JVM targets are compiled for desktop. - Test sharding uses `fail-fast: false` so a failure in one shard does not cancel the others. - JUnit Platform parallel execution is enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races (JVM-global singleton used by 19+ ViewModel test classes). Cross-module parallelism comes from Gradle forks (`maxParallelForks`). - `test-retry` plugin (maxRetries=2, maxFailures=10) is applied to all module types: `AndroidApplicationConventionPlugin`, `AndroidLibraryConventionPlugin`, and `KmpLibraryConventionPlugin`. @@ -180,7 +180,7 @@ Always run commands in the following order to ensure reliability. Do not attempt - **Runner strategy (three tiers):** - **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). These run only shell scripts or GitHub API calls and benefit from ARM runners' shorter queue times. - **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin where possible for reproducibility. - - **Desktop runners:** Reusable CI uses `ubuntu-24.04` for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`. + - **Desktop runners:** Reusable CI uses a multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`. - **CI Gradle properties:** `gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties` before any Gradle invocation. Key CI overrides: `org.gradle.daemon=false` (single-use runners), `kotlin.incremental=false` (fresh checkouts), `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon, VFS watching disabled, workers capped at 4, `org.gradle.isolated-projects=true` for better parallelism. Disables unused Android build features (`resvalues`, `shaders`). This follows the nowinandroid `ci-gradle.properties` pattern. - **CI optimization strategies (2026):** Applied comprehensive CI optimizations (P0-P3): - **P0 (merged Gradle invocations):** `lint-check` merges spotlessCheck, detekt, android lint, and kmpSmokeCompile into a single Gradle invocation to avoid 3x cold-start overhead. Uses `filter: 'blob:none'` for blobless git clone. Switches submodules from `'recursive'` to boolean (saves overhead on nested submodule discovery). diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 6c4239a0f..bcaab0590 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -20,6 +20,8 @@ import com.mikepenz.aboutlibraries.plugin.DuplicateRule import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.meshtastic.buildlogic.GitVersionValueSource +import org.meshtastic.buildlogic.configProperties plugins { alias(libs.plugins.kotlin.jvm) @@ -32,6 +34,71 @@ plugins { alias(libs.plugins.aboutlibraries) } +// ── Version resolution (mirrors app/build.gradle.kts) ──────────────────────── +val gitVersionProvider = providers.of(GitVersionValueSource::class.java) {} + +val vcOffset = configProperties.getProperty("VERSION_CODE_OFFSET")?.toInt() ?: 0 +val resolvedVersionCode: Int = + project.findProperty("android.injected.version.code")?.toString()?.toInt() + ?: System.getenv("VERSION_CODE")?.toInt() + ?: (gitVersionProvider.get().toInt() + vcOffset) +val resolvedVersionName: String = + project.findProperty("android.injected.version.name")?.toString() + ?: project.findProperty("appVersionName")?.toString() + ?: System.getenv("VERSION_NAME") + ?: configProperties.getProperty("VERSION_NAME_BASE") + ?: "1.0.0" +val resolvedIsDebug: Boolean = project.findProperty("desktop.release")?.toString()?.toBoolean()?.not() ?: true +val resolvedMinFwVersion: String = configProperties.getProperty("MIN_FW_VERSION") ?: "" +val resolvedAbsMinFwVersion: String = configProperties.getProperty("ABS_MIN_FW_VERSION") ?: "" + +// ── Generate DesktopBuildConfig ────────────────────────────────────────────── +// Mirrors AGP's BuildConfig for Android so the desktop runtime has access to the +// same version metadata without hardcoding. +// Uses an abstract task with typed properties so the configuration cache can +// serialise it without capturing build-script object references. +@CacheableTask +abstract class GenerateBuildConfigTask : DefaultTask() { + @get:Input abstract val content: Property + + @get:OutputDirectory abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val dir = outputDir.get().asFile + dir.mkdirs() + dir.resolve("DesktopBuildConfig.kt").writeText(content.get()) + } +} + +val buildConfigOutputDir = layout.buildDirectory.dir("generated/buildconfig") + +val generateBuildConfig = + tasks.register("generateDesktopBuildConfig") { + content.set( + """ + |package org.meshtastic.desktop + | + |/** + | * Auto-generated build configuration for Meshtastic Desktop. + | * Do not edit — values are derived from config.properties and git at build time. + | */ + |object DesktopBuildConfig { + | const val VERSION_CODE: Int = $resolvedVersionCode + | const val VERSION_NAME: String = "$resolvedVersionName" + | const val IS_DEBUG: Boolean = $resolvedIsDebug + | const val APPLICATION_ID: String = "org.meshtastic.desktop" + | const val MIN_FW_VERSION: String = "$resolvedMinFwVersion" + | const val ABS_MIN_FW_VERSION: String = "$resolvedAbsMinFwVersion" + |} + """ + .trimMargin(), + ) + outputDir.set(buildConfigOutputDir.map { it.dir("org/meshtastic/desktop") }) + } + +sourceSets.main { kotlin.srcDir(generateBuildConfig.map { buildConfigOutputDir }) } + kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(21)) @@ -70,6 +137,7 @@ compose.desktop { // jdeps might miss some of these if they are loaded via reflection or JNI. modules( "java.net.http", // Ktor Java client + "jdk.accessibility", // Java Access Bridge for screen readers (JAWS, NVDA, VoiceOver) "jdk.crypto.ec", // Required for SSL/TLS HTTPS requests "jdk.unsupported", // sun.misc.Unsafe used by Coroutines & Okio "java.sql", // Sometimes required by SQLite JNI @@ -95,6 +163,17 @@ compose.desktop { """ NSUserNotificationAlertStyle alert + CFBundleURLTypes + + + CFBundleURLName + Meshtastic deep link + CFBundleURLSchemes + + meshtastic + + + """ .trimIndent() } @@ -125,14 +204,9 @@ compose.desktop { else -> targetFormats(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) } - // Read version from project properties (passed by CI) or default to 1.0.0 - // Native installers require strict numeric semantic versions (X.Y.Z) without suffixes - val rawVersion = - project.findProperty("android.injected.version.name")?.toString() - ?: project.findProperty("appVersionName")?.toString() - ?: System.getenv("VERSION_NAME") - ?: "1.0.0" - val sanitizedVersion = Regex("^\\d+\\.\\d+\\.\\d+").find(rawVersion)?.value ?: "1.0.0" + // Reuse the resolved version from the top of this script (mirrors app/build.gradle.kts). + // Native installers require strict numeric semantic versions (X.Y.Z) without suffixes. + val sanitizedVersion = Regex("^\\d+\\.\\d+\\.\\d+").find(resolvedVersionName)?.value ?: "1.0.0" packageVersion = sanitizedVersion description = "Meshtastic Desktop Application" diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt index e2fe40da4..6b0aa1b2a 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -39,6 +39,7 @@ import org.meshtastic.core.datastore.serializer.ChannelSetSerializer import org.meshtastic.core.datastore.serializer.LocalConfigSerializer import org.meshtastic.core.datastore.serializer.LocalStatsSerializer import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer +import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig @@ -90,15 +91,15 @@ fun desktopPlatformModule() = module { includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope)) - // -- Build config -- + // -- Build config (values generated at build time by generateDesktopBuildConfig) -- single { object : BuildConfigProvider { - override val isDebug: Boolean = true - override val applicationId: String = "org.meshtastic.desktop" - override val versionCode: Int = 1 - override val versionName: String = "2.7.14" - override val absoluteMinFwVersion: String = "2.3.15" - override val minFwVersion: String = "2.5.14" + override val isDebug: Boolean = DesktopBuildConfig.IS_DEBUG + override val applicationId: String = DesktopBuildConfig.APPLICATION_ID + override val versionCode: Int = DesktopBuildConfig.VERSION_CODE + override val versionName: String = DesktopBuildConfig.VERSION_NAME + override val absoluteMinFwVersion: String = DesktopBuildConfig.ABS_MIN_FW_VERSION + override val minFwVersion: String = DesktopBuildConfig.MIN_FW_VERSION } } From b3d0c97206db173839fd938428dcc3f43ef7349c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 04:53:07 -0500 Subject: [PATCH 006/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5065) --- app/src/main/assets/firmware_releases.json | 9 +-------- .../commonMain/composeResources/values-et/strings.xml | 6 ++++++ .../commonMain/composeResources/values-fi/strings.xml | 6 ++++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json index 4d74c2b5a..c639f39e2 100644 --- a/app/src/main/assets/firmware_releases.json +++ b/app/src/main/assets/firmware_releases.json @@ -187,12 +187,5 @@ } ] }, - "pullRequests": [ - { - "id": "9999", - "title": "Use UDP as roof node <---> indoor nodes backchannel", - "page_url": "https://github.com/meshtastic/firmware/pull/9999", - "zip_url": "https://discord.com/invite/meshtastic" - } - ] + "pullRequests": [] } \ No newline at end of file diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 6c4b32bc8..969d46acb 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -858,6 +858,12 @@ Sisesta sõnum Pax mõõdiku logi PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s Pax mõõdikut pole saadaval. WiFi ühenduse loomine mPWRD-OS-i jaoks Sinihamba seade diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index 8685b0380..98a2fc84c 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -858,6 +858,12 @@ Kirjoita viesti Pax mittarit PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s PAX mittareita ei ole saatavilla. WiFi-määritys mPWRD-OS:lle Bluetooth-laitteet From 0441093ce86bad683a8a73f80381a2966ee7efec Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:06:17 -0500 Subject: [PATCH 007/114] refactor(node): move Position to last in telemetry list on node details (#5068) --- .../node/component/TelemetricActionsSection.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index 22588aebd..f3a71b374 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -137,14 +137,6 @@ private fun rememberTelemetricFeatures( requestAction = { NodeMenuAction.RequestUserInfo(it) }, isVisible = { !isLocal }, ), - TelemetricFeature( - titleRes = LogsType.POSITIONS.titleRes, - icon = LogsType.POSITIONS.icon, - requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) }, - logsType = LogsType.POSITIONS, - content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) }, - hasContent = { it.latitude != 0.0 || it.longitude != 0.0 }, - ), TelemetricFeature( titleRes = LogsType.TRACEROUTE.titleRes, icon = LogsType.TRACEROUTE.icon, @@ -208,6 +200,14 @@ private fun rememberTelemetricFeatures( requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) }, logsType = LogsType.PAX, ), + TelemetricFeature( + titleRes = LogsType.POSITIONS.titleRes, + icon = LogsType.POSITIONS.icon, + requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) }, + logsType = LogsType.POSITIONS, + content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) }, + hasContent = { it.latitude != 0.0 || it.longitude != 0.0 }, + ), ) } From 1fe3f4423dd516c8bbe257ef5e4c64ee371337b9 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:06:44 -0500 Subject: [PATCH 008/114] fix(ui): add missing @ParameterName annotations on actual rememberReadTextFromUri declarations (#5072) --- .../kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt | 2 +- .../jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 97a24d54e..559169139 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -139,7 +139,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT @Suppress("Wrapping") @Composable -actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? { +actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? { val context = LocalContext.current return remember(context) { { uri, maxChars -> diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 0e06fc398..aa3435d29 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT /** JVM — Reads text from a file URI. */ @Composable -actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? = { uri, maxChars -> +actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> withContext(Dispatchers.IO) { @Suppress("TooGenericExceptionCaught") try { From 40ea45a4fe2c4988a0ca53324a29eb5573833c1b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:06:53 -0500 Subject: [PATCH 009/114] fix(settings): hide Status Message config until firmware v2.8.0 (#5070) --- .../kotlin/org/meshtastic/core/model/Capabilities.kt | 6 +++--- .../kotlin/org/meshtastic/core/model/CapabilitiesTest.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 65096604f..25b9d812c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -49,8 +49,8 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */ val supportsQrCodeSharing = atLeast(V2_6_8) - /** Support for Status Message module. Supported since firmware v2.7.17. */ - val supportsStatusMessage = atLeast(V2_7_17) + /** Support for Status Message module. Supported since firmware v2.8.0. */ + val supportsStatusMessage = atLeast(V2_8_0) /** Support for Traffic Management module. Supported since firmware v3.0.0. */ val supportsTrafficManagementConfig = atLeast(V3_0_0) @@ -69,9 +69,9 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl private val V2_6_9 = DeviceVersion("2.6.9") private val V2_6_10 = DeviceVersion("2.6.10") private val V2_7_12 = DeviceVersion("2.7.12") - private val V2_7_17 = DeviceVersion("2.7.17") private val V2_7_18 = DeviceVersion("2.7.18") private val V2_7_19 = DeviceVersion("2.7.19") + private val V2_8_0 = DeviceVersion("2.8.0") private val V3_0_0 = DeviceVersion("3.0.0") private val UNRELEASED = DeviceVersion("9.9.9") } diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt index ecaf88db6..365a47c61 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt @@ -68,9 +68,9 @@ class CapabilitiesTest { } @Test - fun supportsStatusMessage_requires_V2_7_17() { - assertFalse(caps("2.7.16").supportsStatusMessage) - assertTrue(caps("2.7.17").supportsStatusMessage) + fun supportsStatusMessage_requires_V2_8_0() { + assertFalse(caps("2.7.21").supportsStatusMessage) + assertTrue(caps("2.8.0").supportsStatusMessage) } @Test From 5f0e60eb2182a32f8b0b8d31141db3ce7beee025 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:07:04 -0500 Subject: [PATCH 010/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5067) --- app/src/main/assets/firmware_releases.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json index c639f39e2..4d74c2b5a 100644 --- a/app/src/main/assets/firmware_releases.json +++ b/app/src/main/assets/firmware_releases.json @@ -187,5 +187,12 @@ } ] }, - "pullRequests": [] + "pullRequests": [ + { + "id": "9999", + "title": "Use UDP as roof node <---> indoor nodes backchannel", + "page_url": "https://github.com/meshtastic/firmware/pull/9999", + "zip_url": "https://discord.com/invite/meshtastic" + } + ] } \ No newline at end of file From a3c0a4832d139b9ed15cc2fdd22d9d2c0bef1db4 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:56:29 -0500 Subject: [PATCH 011/114] fix(transport): Kable BLE audit + thread-safety, MQTT, and logging fixes across transport layers (#5071) --- .../org/meshtastic/app/di/NetworkModule.kt | 6 +- core/ble/build.gradle.kts | 5 +- .../core/ble/AndroidBluetoothRepository.kt | 19 +- .../meshtastic/core/ble/KablePlatformSetup.kt | 22 +- .../core/ble/ActiveBleConnection.kt | 11 +- .../org/meshtastic/core/ble/BleConnection.kt | 16 +- .../meshtastic/core/ble/BleConnectionState.kt | 49 +++- .../core/ble/BleExceptionClassifier.kt | 55 ++++ .../meshtastic/core/ble/DirectBleDevice.kt | 50 ---- .../meshtastic/core/ble/KableBleConnection.kt | 144 ++++++----- .../core/ble/KableBleConnectionFactory.kt | 6 + .../meshtastic/core/ble/KableBleScanner.kt | 15 +- .../core/ble/KableMeshtasticRadioProfile.kt | 113 ++++----- .../meshtastic/core/ble/KableStateMapping.kt | 39 ++- .../meshtastic/core/ble/KermitLogEngine.kt | 51 ++++ .../core/ble/MeshtasticBleConstants.kt | 2 - ...bleBleDevice.kt => MeshtasticBleDevice.kt} | 38 ++- .../core/ble/MeshtasticRadioProfile.kt | 18 ++ .../core/ble/BleExceptionClassifierTest.kt | 67 +++++ .../core/ble/DisconnectReasonTest.kt | 51 ++++ .../ble/KableMeshtasticRadioProfileTest.kt | 129 ++++++++++ .../core/ble/KableStateMappingTest.kt | 143 +++++++++++ .../data/manager/MeshConfigFlowManagerImpl.kt | 4 +- core/network/build.gradle.kts | 1 + .../core/network/radio/InterfaceFactory.kt | 16 +- .../core/network/radio/SerialInterface.kt | 11 +- .../core/network/radio/SerialInterfaceSpec.kt | 13 +- ...rfaceFactorySpi.kt => KermitHttpLogger.kt} | 28 ++- .../core/network/radio/BleRadioInterface.kt | 238 +++++++++--------- .../core/network/radio/StreamInterface.kt | 8 +- .../network/repository/MQTTRepositoryImpl.kt | 36 ++- .../network/radio/BleRadioInterfaceTest.kt | 19 +- .../network/radio/ReconnectBackoffTest.kt | 17 +- .../core/network/radio/TCPInterface.kt | 7 +- .../core/network/transport/TcpTransport.kt | 32 ++- .../org/meshtastic/core/testing/FakeBle.kt | 10 +- desktop/build.gradle.kts | 1 + .../desktop/di/DesktopKoinModule.kt | 15 +- .../feature/firmware/ota/BleOtaTransport.kt | 39 +-- .../feature/firmware/ota/BleScanSupport.kt | 6 +- .../firmware/ota/dfu/SecureDfuTransport.kt | 45 ++-- .../ota/dfu/SecureDfuTransportTest.kt | 4 +- .../wifiprovision/NymeaBleConstants.kt | 10 +- .../wifiprovision/domain/NymeaWifiService.kt | 27 +- 44 files changed, 1123 insertions(+), 513 deletions(-) create mode 100644 core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt delete mode 100644 core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt create mode 100644 core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt rename core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/{KableBleDevice.kt => MeshtasticBleDevice.kt} (53%) create mode 100644 core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt create mode 100644 core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt create mode 100644 core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt create mode 100644 core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt rename core/network/src/commonMain/kotlin/org/meshtastic/core/network/{radio/InterfaceFactorySpi.kt => KermitHttpLogger.kt} (50%) diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt index 7f6fb0215..4aa27bf0e 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt @@ -40,6 +40,7 @@ import okio.Path.Companion.toOkioPath import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.common.BuildConfigProvider +import org.meshtastic.core.network.KermitHttpLogger private const val DISK_CACHE_PERCENT = 0.02 private const val MEMORY_CACHE_PERCENT = 0.25 @@ -84,7 +85,10 @@ class NetworkModule { HttpClient(engineFactory = Android) { install(plugin = ContentNegotiation) { json(json) } if (buildConfigProvider.isDebug) { - install(plugin = Logging) { level = LogLevel.BODY } + install(plugin = Logging) { + logger = KermitHttpLogger + level = LogLevel.BODY + } } } } diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts index b61fad0e7..d26431634 100644 --- a/core/ble/build.gradle.kts +++ b/core/ble/build.gradle.kts @@ -46,7 +46,10 @@ kotlin { implementation(libs.jetbrains.lifecycle.runtime) } - commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } + commonTest.dependencies { + implementation(libs.kotlinx.coroutines.test) + implementation(projects.core.testing) + } val androidHostTest by getting { dependencies { diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt index c8d444688..5b17e264b 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt @@ -49,7 +49,7 @@ class AndroidBluetoothRepository( private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions())) override val state: StateFlow = _state.asStateFlow() - private val deviceCache = mutableMapOf() + private val deviceCache = mutableMapOf() init { processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() } @@ -180,14 +180,15 @@ class AndroidBluetoothRepository( // user renamed the device in firmware since the cache was populated. deviceCache.keys.retainAll(bondedAddresses) return bonded.map { device -> - deviceCache - .getOrPut(device.address) { DirectBleDevice(device.address, device.name) } - .also { cached -> - // Refresh name if it changed (firmware rename, etc.) - if (cached.name != device.name) { - deviceCache[device.address] = DirectBleDevice(device.address, device.name) - } - } + val cached = deviceCache.getOrPut(device.address) { MeshtasticBleDevice(device.address, device.name) } + // If the name changed (firmware rename, etc.), replace the cached entry and return the new one. + if (cached.name != device.name) { + val updated = MeshtasticBleDevice(device.address, device.name) + deviceCache[device.address] = updated + updated + } else { + cached + } } } diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index e9928f8d5..b0617635a 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -20,15 +20,29 @@ import co.touchlab.kermit.Logger import com.juul.kable.AndroidPeripheral import com.juul.kable.Peripheral import com.juul.kable.PeripheralBuilder +import com.juul.kable.PooledThreadingStrategy import com.juul.kable.toIdentifier +/** + * Shared thread pool for Kable BLE connections. + * + * [PooledThreadingStrategy] reuses handler threads across reconnect cycles, avoiding the overhead of creating a new + * thread per connection attempt that [OnDemandThreadingStrategy][com.juul.kable.OnDemandThreadingStrategy] incurs. Idle + * threads are evicted after 1 minute (default). + * + * A single app-wide instance is used because Kable recommends exactly one pool per application. + */ +private val sharedThreadingStrategy = PooledThreadingStrategy() + internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) { - // If we're connecting blindly to a bonded device without a fresh scan (DirectBleDevice), - // we MUST use autoConnect = true. Otherwise, Android's direct connect algorithm will often fail - // immediately with GATT 133 or timeout, especially if the device uses random resolvable addresses. - // If we just scanned the device (KableBleDevice), direct connection (autoConnect = false) is faster. + // Bonded devices without a fresh advertisement must use autoConnect = true. Otherwise, + // Android's direct connect algorithm often fails with GATT 133 or times out, especially + // if the device uses random resolvable addresses. Scanned devices (advertisement != null) + // use direct connection (autoConnect = false) for faster initial connects. autoConnectIf(autoConnect) + threadingStrategy = sharedThreadingStrategy + onServicesDiscovered { try { // Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes. diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt index 1bfaff648..1ea11622d 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt @@ -19,14 +19,17 @@ package org.meshtastic.core.ble import com.juul.kable.Peripheral import kotlin.concurrent.Volatile +/** Snapshot of the currently active BLE peripheral and its address, updated atomically. */ +internal data class ActiveConnection(val peripheral: Peripheral, val address: String) + /** * A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between * dynamically created UI devices (scanned vs bonded) and the actual connection. * - * Fields are volatile to ensure visibility across AIDL binder threads and coroutine dispatchers. + * [active] is a single volatile reference so readers always see a consistent peripheral/address pair — the previous + * two-field design (`activePeripheral` + `activeAddress`) was susceptible to TOCTOU races when fields were updated + * non-atomically. */ internal object ActiveBleConnection { - @Volatile var activePeripheral: Peripheral? = null - - @Volatile var activeAddress: String? = null + @Volatile var active: ActiveConnection? = null } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt index 06496aeea..59cf134de 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.ble import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.onStart import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @@ -49,8 +50,8 @@ interface BleConnection { /** Connects to the given [BleDevice]. */ suspend fun connect(device: BleDevice) - /** Connects to the given [BleDevice] and waits for a terminal state. */ - suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState + /** Connects to the given [BleDevice] and waits for a terminal state or [timeout]. */ + suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState /** Disconnects from the current device. */ suspend fun disconnect() @@ -77,6 +78,17 @@ interface BleService { /** Observes notifications/indications from the characteristic. */ fun observe(characteristic: BleCharacteristic): Flow + /** + * Observes notifications/indications from the characteristic with an [onSubscription] action that fires **after** + * notifications are enabled (CCCD written). + * + * The [onSubscription] is re-invoked on every reconnect while the returned [Flow] is active. The default + * implementation invokes [onSubscription] eagerly on flow start so non-Kable implementations still signal + * readiness. + */ + fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit): Flow = + observe(characteristic).onStart { onSubscription() } + /** Reads the characteristic value once. */ suspend fun read(characteristic: BleCharacteristic): ByteArray diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt index a9f82c5f9..2026b0cb1 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt @@ -17,16 +17,53 @@ package org.meshtastic.core.ble /** Represents the state of a BLE connection. */ -sealed class BleConnectionState { - /** The peripheral is disconnected. */ - object Disconnected : BleConnectionState() +sealed interface BleConnectionState { + + /** + * The peripheral is disconnected. + * + * @param reason why the disconnect occurred. [DisconnectReason.Unknown] when the platform doesn't provide status + * information (e.g. JavaScript) or when the disconnect was synthesised locally without a GATT callback. + */ + data class Disconnected(val reason: DisconnectReason = DisconnectReason.Unknown) : BleConnectionState /** The peripheral is connecting. */ - object Connecting : BleConnectionState() + data object Connecting : BleConnectionState /** The peripheral is connected. */ - object Connected : BleConnectionState() + data object Connected : BleConnectionState /** The peripheral is disconnecting. */ - object Disconnecting : BleConnectionState() + data object Disconnecting : BleConnectionState +} + +/** + * Platform-agnostic reason for a BLE disconnect. + * + * Mapped from Kable's [com.juul.kable.State.Disconnected.Status] in `KableStateMapping`. + */ +sealed interface DisconnectReason { + /** Cause is unknown or the platform did not report one. */ + data object Unknown : DisconnectReason + + /** The local app/central initiated the disconnect. */ + data object LocalDisconnect : DisconnectReason + + /** The remote peripheral (firmware) initiated the disconnect. */ + data object RemoteDisconnect : DisconnectReason + + /** A connection attempt failed to establish. */ + data object ConnectionFailed : DisconnectReason + + /** The BLE link supervision timed out (device went out of range). */ + data object Timeout : DisconnectReason + + /** The connection was explicitly cancelled. */ + data object Cancelled : DisconnectReason + + /** An encryption or authentication failure occurred. */ + data object EncryptionFailed : DisconnectReason + + /** Platform-specific status code that doesn't map to a known reason. */ + data class PlatformSpecific(val code: Int) : DisconnectReason } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt new file mode 100644 index 000000000..6f5180b60 --- /dev/null +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("MatchingDeclarationName") // File groups the classifier function and its result type. + +package org.meshtastic.core.ble + +import com.juul.kable.GattRequestRejectedException +import com.juul.kable.GattStatusException +import com.juul.kable.NotConnectedException +import com.juul.kable.UnmetRequirementException + +/** + * Classification of a BLE-layer exception for the transport layer to act on. + * + * @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled). + * @property gattStatus the platform GATT status code when available (Android-specific). + * @property message a human-readable description of the failure. + */ +data class BleExceptionInfo(val isPermanent: Boolean, val gattStatus: Int? = null, val message: String) + +/** + * Inspects this [Throwable] and returns a [BleExceptionInfo] if it is a known Kable exception, or `null` if it is + * unrelated to the BLE layer. + * + * This keeps Kable type knowledge inside `core:ble` so that `core:network` (and other consumers) can classify BLE + * exceptions without depending on Kable directly. + */ +fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) { + is GattStatusException -> + BleExceptionInfo( + isPermanent = false, + gattStatus = status, + message = "GATT error (status $status): $message", + ) + is NotConnectedException -> BleExceptionInfo(isPermanent = false, message = "Not connected") + is GattRequestRejectedException -> + BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)") + is UnmetRequirementException -> + BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable") + else -> null +} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt deleted file mode 100644 index 9e32e4602..000000000 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.ble - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** Represents a BLE device known by address only (e.g. from bonded list) without an active advertisement. */ -class DirectBleDevice(override val address: String, override val name: String? = null) : BleDevice { - private val _state = MutableStateFlow(BleConnectionState.Disconnected) - override val state: StateFlow = _state.asStateFlow() - - override val isBonded: Boolean = true - - override val isConnected: Boolean - get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address - - @OptIn(com.juul.kable.ExperimentalApi::class) - override suspend fun readRssi(): Int { - val peripheral = ActiveBleConnection.activePeripheral - return if (peripheral != null && ActiveBleConnection.activeAddress == address) { - peripheral.rssi() - } else { - 0 - } - } - - override suspend fun bond() { - // DirectBleDevice assumes we are already bonded. - } - - fun updateState(newState: BleConnectionState) { - _state.value = newState - } -} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index 5265127c1..dde1955a5 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -18,9 +18,11 @@ package org.meshtastic.core.ble import co.touchlab.kermit.Logger import com.juul.kable.Peripheral +import com.juul.kable.PeripheralBuilder import com.juul.kable.State import com.juul.kable.WriteType import com.juul.kable.characteristicOf +import com.juul.kable.logs.Logging import com.juul.kable.writeWithoutResponse import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -30,7 +32,6 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.launchIn @@ -39,6 +40,7 @@ import kotlinx.coroutines.job import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid /** [BleService] implementation backed by a Kable [Peripheral] for a specific GATT service. */ @@ -50,6 +52,9 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui override fun observe(characteristic: BleCharacteristic) = peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid)) + override fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit) = + peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid), onSubscription) + override suspend fun read(characteristic: BleCharacteristic): ByteArray = peripheral.read(characteristicOf(serviceUuid, characteristic.uuid)) @@ -78,8 +83,11 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui /** * [BleConnection] implementation using Kable for cross-platform BLE communication. * - * Manages peripheral lifecycle (connect with exponential backoff, disconnect, reconnect), connection state tracking, - * and GATT service profile access. + * Manages peripheral lifecycle, connection state tracking, and GATT service profile access. + * + * Connection attempts follow Kable's recommended pattern from the SensorTag sample: try a direct connect first, then + * fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller + * ([BleRadioInterface]) owns the macro-level retry/backoff loop. */ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { @@ -88,10 +96,8 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { private var connectionScope: CoroutineScope? = null companion object { - private const val INITIAL_RETRY_DELAY_MS = 1000L - private const val MAX_RETRY_DELAY_MS = 30_000L - private const val MAX_CONNECT_RETRIES = 15 - private const val BACKOFF_MULTIPLIER = 2 + /** Settle delay between a direct connect failure and the autoConnect fallback attempt. */ + private val AUTOCONNECT_FALLBACK_DELAY = 1.seconds } private val _deviceFlow = MutableSharedFlow(replay = 1) @@ -108,47 +114,32 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { ) override val connectionState: SharedFlow = _connectionState.asSharedFlow() - @Suppress("LongMethod", "CyclomaticComplexMethod") + @Suppress("CyclomaticComplexMethod", "LongMethod") override suspend fun connect(device: BleDevice) { - val autoConnect = MutableStateFlow(device is DirectBleDevice) + val meshtasticDevice = device as? MeshtasticBleDevice ?: error("Unsupported BleDevice type: ${device::class}") + var autoConnect = meshtasticDevice.advertisement == null + + /** Applies logging, observation exception handling, and platform config shared by both peripheral types. */ + fun PeripheralBuilder.commonConfig() { + logging { + engine = KermitLogEngine + level = Logging.Level.Events + identifier = device.address + } + observationExceptionHandler { cause -> + Logger.w(cause) { "[${device.address}] Observation failure suppressed" } + } + platformConfig(device) { autoConnect } + } val p = - when (device) { - is KableBleDevice -> - Peripheral(device.advertisement) { - observationExceptionHandler { cause -> - Logger.w(cause) { "[${device.address}] Observation failure suppressed" } - } - platformConfig(device) { autoConnect.value } - } - is DirectBleDevice -> - createPeripheral(device.address) { - observationExceptionHandler { cause -> - Logger.w(cause) { "[${device.address}] Observation failure suppressed" } - } - platformConfig(device) { autoConnect.value } - } - else -> error("Unsupported BleDevice type: ${device::class}") - } + meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } } + ?: createPeripheral(device.address) { commonConfig() } - // Clean up previous peripheral under NonCancellable to prevent GATT resource leaks - // if the calling coroutine is cancelled during teardown. - withContext(NonCancellable) { - try { - peripheral?.disconnect() - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "[${device.address}] Failed to disconnect previous peripheral" } - } - try { - peripheral?.close() - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "[${device.address}] Failed to close previous peripheral" } - } - } + cleanUpPeripheral(device.address) peripheral = p - ActiveBleConnection.activePeripheral = p - ActiveBleConnection.activeAddress = device.address + ActiveBleConnection.active = ActiveConnection(p, device.address) _deviceFlow.emit(device) @@ -162,21 +153,15 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { hasStartedConnecting = true } - when (device) { - is KableBleDevice -> device.updateState(mappedState) - is DirectBleDevice -> device.updateState(mappedState) - } + meshtasticDevice.updateState(mappedState) _connectionState.emit(mappedState) } .launchIn(scope) - var retryCount = 0 - var retryDelayMs = INITIAL_RETRY_DELAY_MS while (p.state.value !is State.Connected) { - autoConnect.value = + autoConnect = try { - // Cancel any previous connectionScope to avoid leaking the old coroutine scope. connectionScope?.let { oldScope -> Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" } oldScope.coroutineContext.job.cancel() @@ -185,52 +170,50 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { false } catch (e: CancellationException) { throw e - } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) { - retryCount++ - if (retryCount > MAX_CONNECT_RETRIES) { - Logger.w { "[${device.address}] Max connect retries ($MAX_CONNECT_RETRIES) exceeded" } - _connectionState.emit(BleConnectionState.Disconnected) - return + } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) { + if (autoConnect) { + // autoConnect already true and still failed — don't loop forever. + Logger.w { "[${device.address}] autoConnect attempt failed, giving up" } + _connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed)) + throw e } - Logger.d { "[${device.address}] Connect retry $retryCount, backoff ${retryDelayMs}ms" } - delay(retryDelayMs) - retryDelayMs = (retryDelayMs * BACKOFF_MULTIPLIER).coerceAtMost(MAX_RETRY_DELAY_MS) + Logger.d { "[${device.address}] Direct connect failed, falling back to autoConnect" } + delay(AUTOCONNECT_FALLBACK_DELAY) true } } } @Suppress("TooGenericExceptionCaught", "SwallowedException") - override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try { - withTimeout(timeoutMs) { + override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState = try { + withTimeout(timeout) { connect(device) BleConnectionState.Connected } } catch (_: TimeoutCancellationException) { // Our own timeout expired — treat as a failed attempt so callers can retry. - BleConnectionState.Disconnected + BleConnectionState.Disconnected(DisconnectReason.Timeout) } catch (e: CancellationException) { // External cancellation (scope closed) — must propagate. throw e } catch (_: Exception) { - BleConnectionState.Disconnected + BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed) } override suspend fun disconnect() = withContext(NonCancellable) { // Emit Disconnected before cancelling stateJob so downstream collectors see the // state transition. If we cancel stateJob first, the peripheral's state flow // emission of Disconnected is never forwarded to _connectionState. - _connectionState.emit(BleConnectionState.Disconnected) + _connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.LocalDisconnect)) stateJob?.cancel() stateJob = null - peripheral?.disconnect() - peripheral?.close() + + safeClosePeripheral("disconnect") peripheral = null connectionScope = null - ActiveBleConnection.activePeripheral = null - ActiveBleConnection.activeAddress = null + ActiveBleConnection.active = null _deviceFlow.emit(null) } @@ -247,4 +230,29 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { } override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength() + + /** Ensures the previous peripheral's GATT resources are fully released. */ + private suspend fun cleanUpPeripheral(tag: String) { + withContext(NonCancellable) { safeClosePeripheral(tag) } + } + + /** + * Safely disconnects and closes the current [peripheral], logging any failures. + * + * Kable requires `close()` to release broadcast receivers on Android (Kable issue #359). Separate try/catch blocks + * ensure `close()` always runs even if `disconnect()` throws. + */ + @Suppress("TooGenericExceptionCaught") + private suspend fun safeClosePeripheral(tag: String) { + try { + peripheral?.disconnect() + } catch (e: Exception) { + Logger.w(e) { "[$tag] Failed to disconnect peripheral" } + } + try { + peripheral?.close() + } catch (e: Exception) { + Logger.w(e) { "[$tag] Failed to close peripheral" } + } + } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt index d0f3a7168..13b8a1663 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt @@ -21,5 +21,11 @@ import org.koin.core.annotation.Single @Single class KableBleConnectionFactory : BleConnectionFactory { + /** + * Creates a new [KableBleConnection]. + * + * [tag] is unused because Kable's own log identifier is set per-peripheral inside [KableBleConnection.connect] + * using the device address, which provides more precise context than a factory-time tag. + */ override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt index d9e27704f..5e91b3459 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.ble import com.juul.kable.Scanner +import com.juul.kable.logs.Logging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withTimeoutOrNull @@ -28,6 +29,10 @@ import kotlin.uuid.Uuid class KableBleScanner : BleScanner { override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow { val scanner = Scanner { + logging { + engine = KermitLogEngine + level = Logging.Level.Events + } // Use separate match blocks so each filter is evaluated independently (OR semantics). // Combining address and service UUID in a single match{} creates an AND filter which // silently drops results on OEM stacks (Samsung, Xiaomi) when the device uses a @@ -43,7 +48,15 @@ class KableBleScanner : BleScanner { // By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly. return channelFlow { withTimeoutOrNull(timeout) { - scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) } + scanner.advertisements.collect { advertisement -> + send( + MeshtasticBleDevice( + address = advertisement.identifier.toString(), + name = advertisement.name, + advertisement = advertisement, + ), + ) + } } } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt index 46ace854f..3f0e61864 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt @@ -18,110 +18,101 @@ package org.meshtastic.core.ble import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC -import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC +import kotlin.time.Duration.Companion.milliseconds /** * [MeshtasticRadioProfile] implementation using Kable BLE characteristics. * - * Supports both the modern `FROMRADIOSYNC` characteristic (single observe stream) and the legacy `FROMNUM` + - * `FROMRADIO` polling fallback for older firmware versions. + * Uses the standard Meshtastic BLE protocol: FROMNUM notifications trigger polling reads on the FROMRADIO + * characteristic. The firmware gates FROMNUM notifications behind `STATE_SEND_PACKETS`, so during the config handshake + * we seed the drain trigger to poll proactively. */ class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile { private val toRadio = service.characteristic(TORADIO_CHARACTERISTIC) private val fromRadioChar = service.characteristic(FROMRADIO_CHARACTERISTIC) - private val fromRadioSync = service.characteristic(FROMRADIOSYNC_CHARACTERISTIC) private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC) private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC) companion object { - private const val TRANSIENT_RETRY_DELAY_MS = 500L + private val TRANSIENT_RETRY_DELAY = 500.milliseconds } - // replay = 1: a seed emission placed here before the collector starts is replayed to the - // collector immediately on subscription. This is what drives the initial FROMRADIO poll - // during the config-handshake phase, where the firmware suppresses FROMNUM notifications - // (it only emits them in STATE_SEND_PACKETS). Without the initial replay the entire config - // stream would be silently skipped on devices that lack FROMRADIOSYNC. + private val subscriptionReady = CompletableDeferred() + + /** Seed with replay=1 so the config-handshake drain starts before FROMNUM notifications are gated in. */ private val triggerDrain = MutableSharedFlow(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) - // Using observe() for fromRadioSync or legacy read loop for fromRadio @Suppress("TooGenericExceptionCaught", "SwallowedException") override val fromRadio: Flow = channelFlow { - // Try to observe FROMRADIOSYNC if available. If it fails, fallback to FROMNUM/FROMRADIO. - // This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation. launch { - try { - if (service.hasCharacteristic(fromRadioSync)) { - service.observe(fromRadioSync).collect { send(it) } - } else { - error("fromRadioSync missing") - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - // Fallback to legacy FROMNUM/FROMRADIO polling. - // Wire up FROMNUM notifications for steady-state packet delivery. - launch { - if (service.hasCharacteristic(fromNum)) { - service.observe(fromNum).collect { triggerDrain.tryEmit(Unit) } + if (service.hasCharacteristic(fromNum)) { + service + .observe(fromNum) { + Logger.d { "FROMNUM CCCD written — notifications enabled" } + subscriptionReady.complete(Unit) } - } - // Seed the replay buffer so the collector below starts draining immediately. - // The firmware does NOT send FROMNUM notifications during the config handshake - // (it gates them on STATE_SEND_PACKETS). Without this seed the entire config - // stream would never be read on devices that lack FROMRADIOSYNC. - triggerDrain.tryEmit(Unit) - triggerDrain.collect { - var keepReading = true - while (keepReading) { - try { - if (!service.hasCharacteristic(fromRadioChar)) { - keepReading = false - continue - } - val packet = service.read(fromRadioChar) - if (packet.isEmpty()) keepReading = false else send(packet) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" } - keepReading = false - // Don't permanently stop — the next triggerDrain emission will retry. - delay(TRANSIENT_RETRY_DELAY_MS) - } + .collect { triggerDrain.tryEmit(Unit) } + } else { + subscriptionReady.complete(Unit) + } + } + triggerDrain.tryEmit(Unit) + triggerDrain.collect { + var keepReading = true + while (keepReading) { + try { + if (!service.hasCharacteristic(fromRadioChar)) { + keepReading = false + continue } + val packet = service.read(fromRadioChar) + if (packet.isEmpty()) keepReading = false else send(packet) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" } + keepReading = false + delay(TRANSIENT_RETRY_DELAY) } } } } - @Suppress("TooGenericExceptionCaught", "SwallowedException") - override val logRadio: Flow = channelFlow { - try { - if (service.hasCharacteristic(logRadioChar)) { - service.observe(logRadioChar).collect { send(it) } + override val logRadio: Flow = + if (service.hasCharacteristic(logRadioChar)) { + service.observe(logRadioChar).catch { e -> + if (e is CancellationException) throw e + // logRadio is optional — swallow observation errors silently. } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - // logRadio is optional, ignore if not found + } else { + emptyFlow() } - } override suspend fun sendToRadio(packet: ByteArray) { service.write(toRadio, packet, service.preferredWriteType(toRadio)) triggerDrain.tryEmit(Unit) } + + override fun requestDrain() { + triggerDrain.tryEmit(Unit) + } + + override suspend fun awaitSubscriptionReady() { + subscriptionReady.await() + } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt index 7a03a3d89..4bd395dc5 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt @@ -25,14 +25,33 @@ import com.juul.kable.State * state emitted by StateFlow upon subscription. * @return the mapped [BleConnectionState], or null if the state should be ignored. */ -fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? { - return when (this) { - is State.Connecting -> BleConnectionState.Connecting - is State.Connected -> BleConnectionState.Connected - is State.Disconnecting -> BleConnectionState.Disconnecting - is State.Disconnected -> { - if (!hasStartedConnecting) return null - BleConnectionState.Disconnected - } - } +fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? = when (this) { + is State.Connecting -> BleConnectionState.Connecting + is State.Connected -> BleConnectionState.Connected + is State.Disconnecting -> BleConnectionState.Disconnecting + is State.Disconnected -> + if (hasStartedConnecting) BleConnectionState.Disconnected(status.toDisconnectReason()) else null +} + +/** + * Maps Kable's [State.Disconnected.Status] to [DisconnectReason]. + * + * Groups platform-specific GATT/CBError codes into broad categories that the reconnect logic can act on without leaking + * platform details. + */ +fun State.Disconnected.Status?.toDisconnectReason(): DisconnectReason = when (this) { + null -> DisconnectReason.Unknown + State.Disconnected.Status.CentralDisconnected -> DisconnectReason.LocalDisconnect + State.Disconnected.Status.PeripheralDisconnected -> DisconnectReason.RemoteDisconnect + State.Disconnected.Status.Failed, + State.Disconnected.Status.L2CapFailure, + -> DisconnectReason.ConnectionFailed + State.Disconnected.Status.Timeout, + State.Disconnected.Status.LinkManagerProtocolTimeout, + -> DisconnectReason.Timeout + State.Disconnected.Status.Cancelled -> DisconnectReason.Cancelled + State.Disconnected.Status.EncryptionTimedOut -> DisconnectReason.EncryptionFailed + State.Disconnected.Status.ConnectionLimitReached -> DisconnectReason.ConnectionFailed + State.Disconnected.Status.UnknownDevice -> DisconnectReason.ConnectionFailed + is State.Disconnected.Status.Unknown -> DisconnectReason.PlatformSpecific(status) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt new file mode 100644 index 000000000..6884dc9e1 --- /dev/null +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import co.touchlab.kermit.Logger +import com.juul.kable.logs.LogEngine + +/** + * Bridges Kable's internal logging to [Kermit][Logger] so BLE lifecycle events (connect, disconnect, subscribe, GATT + * operations) appear in the standard app logs rather than going to [System.out] via Kable's default + * [com.juul.kable.logs.SystemLogEngine]. + */ +internal object KermitLogEngine : LogEngine { + override fun verbose(throwable: Throwable?, tag: String, message: String) { + Logger.v(throwable) { "[$tag] $message" } + } + + override fun debug(throwable: Throwable?, tag: String, message: String) { + Logger.d(throwable) { "[$tag] $message" } + } + + override fun info(throwable: Throwable?, tag: String, message: String) { + Logger.i(throwable) { "[$tag] $message" } + } + + override fun warn(throwable: Throwable?, tag: String, message: String) { + Logger.w(throwable) { "[$tag] $message" } + } + + override fun error(throwable: Throwable?, tag: String, message: String) { + Logger.e(throwable) { "[$tag] $message" } + } + + override fun assert(throwable: Throwable?, tag: String, message: String) { + Logger.e(throwable) { "[$tag] $message" } + } +} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt index 389516521..f69214187 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt @@ -38,8 +38,6 @@ object MeshtasticBleConstants { /** Characteristic for receiving log notifications from the radio. */ val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547") - val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d") - // --- OTA Characteristics --- /** The Meshtastic OTA service UUID (ESP32 Unified OTA). */ diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt similarity index 53% rename from core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt rename to core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt index 455779937..eb2ee2129 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt @@ -19,30 +19,41 @@ package org.meshtastic.core.ble import com.juul.kable.Advertisement import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow -class KableBleDevice(val advertisement: Advertisement) : BleDevice { - override val name: String? - get() = advertisement.name +/** + * Unified [BleDevice] implementation for all BLE devices — scanned, bonded, or both. + * + * When created from a live BLE scan, [advertisement] is populated and used for optimal peripheral construction via + * `Peripheral(advertisement)`. When created from the OS bonded device list (address only), [advertisement] is `null` + * and the peripheral is constructed via `createPeripheral(address)` with `autoConnect = true`. + * + * @param address The device's MAC address (or platform identifier string). + * @param name The device's display name, if known. + * @param advertisement The Kable [Advertisement] from a live scan, or `null` for bonded-only devices. + */ +class MeshtasticBleDevice( + override val address: String, + override val name: String? = null, + val advertisement: Advertisement? = null, +) : BleDevice { - override val address: String - get() = advertisement.identifier.toString() - - private val _state = MutableStateFlow(BleConnectionState.Disconnected) - override val state: StateFlow = _state + private val _state = MutableStateFlow(BleConnectionState.Disconnected()) + override val state: StateFlow = _state.asStateFlow() // Bonding is handled by the OS pairing dialog on Android; on desktop Kable connects directly. override val isBonded: Boolean = true override val isConnected: Boolean - get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address + get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address @OptIn(com.juul.kable.ExperimentalApi::class) override suspend fun readRssi(): Int { - val peripheral = ActiveBleConnection.activePeripheral - return if (peripheral != null && ActiveBleConnection.activeAddress == address) { - peripheral.rssi() + val active = ActiveBleConnection.active + return if (active != null && active.address == address) { + active.peripheral.rssi() } else { - advertisement.rssi + advertisement?.rssi ?: 0 } } @@ -50,6 +61,7 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice { // No-op: bonding is OS-managed on Android and not required on desktop. } + /** Updates the tracked connection state. Called by [KableBleConnection] when the peripheral state changes. */ internal fun updateState(newState: BleConnectionState) { _state.value = newState } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt index d1a557a42..7a69e9524 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt @@ -28,4 +28,22 @@ interface MeshtasticRadioProfile { /** Sends a packet to the radio. */ suspend fun sendToRadio(packet: ByteArray) + + /** + * Requests a drain of the FROMRADIO characteristic without writing to TORADIO. + * + * This is useful when the firmware has queued a response (e.g. `queueStatus` after a heartbeat) but did not send a + * FROMNUM notification. Without an explicit drain trigger the response would sit unread until the next unrelated + * FROMNUM notification arrives. + */ + fun requestDrain() {} + + /** + * Suspends until GATT notifications are enabled (CCCD written) for the primary observation characteristic. + * + * Callers should await this before triggering the Meshtastic handshake (`want_config_id`) to guarantee that FROMNUM + * notifications will be delivered. The default implementation returns immediately for profiles where CCCD readiness + * is not observable (e.g. fakes and non-BLE transports). + */ + suspend fun awaitSubscriptionReady() {} } diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt new file mode 100644 index 000000000..1170b973b --- /dev/null +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import com.juul.kable.GattStatusException +import com.juul.kable.NotConnectedException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for [classifyBleException] — the boundary between Kable types and the transport layer. + * + * [GattRequestRejectedException] and [UnmetRequirementException] have `internal` constructors in Kable, so they cannot + * be instantiated from outside the library. The `else -> null` branch covers the fallback for any unrecognised + * throwable. + */ +class BleExceptionClassifierTest { + + @Test + fun `GattStatusException maps to non-permanent with status code`() { + val ex = GattStatusException(message = "GATT failure", status = 133) + val info = ex.classifyBleException() + assertNotNull(info) + assertFalse(info.isPermanent) + assertEquals(133, info.gattStatus) + assertTrue(info.message.contains("133")) + } + + @Test + fun `NotConnectedException maps to non-permanent without status code`() { + val ex = NotConnectedException("disconnected") + val info = ex.classifyBleException() + assertNotNull(info) + assertFalse(info.isPermanent) + assertNull(info.gattStatus) + assertEquals("Not connected", info.message) + } + + @Test + fun `unrelated exception returns null`() { + val ex = IllegalStateException("something else") + assertNull(ex.classifyBleException()) + } + + @Test + fun `RuntimeException returns null`() { + assertNull(RuntimeException("boom").classifyBleException()) + } +} diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt new file mode 100644 index 000000000..d947dd04d --- /dev/null +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +/** Tests for [DisconnectReason] and [BleConnectionState.Disconnected]. */ +class DisconnectReasonTest { + + @Test + @Suppress("MagicNumber") + fun `PlatformSpecific toString includes status code`() { + val reason = DisconnectReason.PlatformSpecific(133) + val str = reason.toString() + assertContains(str, "133", message = "PlatformSpecific.toString() should include the status code") + } + + @Test + fun `Disconnected default reason is Unknown`() { + val state = BleConnectionState.Disconnected() + assertEquals(DisconnectReason.Unknown, state.reason) + } + + @Test + fun `Disconnected preserves explicit reason`() { + val state = BleConnectionState.Disconnected(DisconnectReason.Timeout) + assertEquals(DisconnectReason.Timeout, state.reason) + } + + @Test + fun `data object reasons are singletons`() { + assertEquals(DisconnectReason.Unknown, DisconnectReason.Unknown) + assertEquals(DisconnectReason.LocalDisconnect, DisconnectReason.LocalDisconnect) + } +} diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt new file mode 100644 index 000000000..8068c9387 --- /dev/null +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.testing.FakeBleService +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for [KableMeshtasticRadioProfile] — the GATT characteristic orchestration layer. + * + * Uses [FakeBleService] from `core:testing`. Since [FakeBleService] inherits the default [BleService.observe] overload + * (which invokes `onSubscription` via `onStart`), `awaitSubscriptionReady()` completes immediately — matching the + * behaviour expected from non-Kable implementations. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class KableMeshtasticRadioProfileTest { + + private fun createService(): FakeBleService = FakeBleService().apply { + addCharacteristic(MeshtasticBleConstants.FROMNUM_CHARACTERISTIC) + addCharacteristic(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC) + addCharacteristic(MeshtasticBleConstants.TORADIO_CHARACTERISTIC) + } + + @Test + fun `awaitSubscriptionReady completes when using FakeBleService`() = runTest { + val service = createService() + val profile = KableMeshtasticRadioProfile(service) + + // Start collecting fromRadio to activate the observe() flow (which triggers onSubscription) + val collectJob = launch { profile.fromRadio.first() } + advanceUntilIdle() + + // Should not hang — FakeBleService's default observe(char, onSubscription) fires onSubscription eagerly + profile.awaitSubscriptionReady() + + collectJob.cancel() + } + + @Test + fun `sendToRadio writes to TORADIO and triggers drain`() = runTest { + val service = createService() + val profile = KableMeshtasticRadioProfile(service) + val testData = byteArrayOf(1, 2, 3) + + // Enqueue empty read so the drain loop terminates + service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0)) + + profile.sendToRadio(testData) + + assertEquals(1, service.writes.size) + assertTrue(service.writes[0].data.contentEquals(testData)) + } + + @Test + fun `fromRadio emits packets from FROMRADIO reads`() = runTest { + val service = createService() + val profile = KableMeshtasticRadioProfile(service) + + val packet1 = byteArrayOf(10, 20, 30) + service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, packet1) + // Empty read terminates the drain loop + service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0)) + + val received = async { profile.fromRadio.first() } + advanceUntilIdle() + + assertTrue(received.await().contentEquals(packet1)) + } + + @Test + fun `requestDrain triggers additional FROMRADIO reads`() = runTest { + val service = createService() + val profile = KableMeshtasticRadioProfile(service) + + val received = mutableListOf() + + // Start the fromRadio collector + val collectJob = launch { profile.fromRadio.collect { received.add(it) } } + advanceUntilIdle() + + // First drain should have completed (initial seed) with nothing queued. + // Now enqueue a packet and trigger a manual drain. + val latePacket = byteArrayOf(99) + service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, latePacket) + service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0)) + profile.requestDrain() + advanceUntilIdle() + + assertEquals(1, received.size) + assertTrue(received[0].contentEquals(latePacket)) + + collectJob.cancel() + } + + @Test + fun `MeshtasticRadioProfile default awaitSubscriptionReady returns immediately`() = runTest { + val profile = + object : MeshtasticRadioProfile { + override val fromRadio = kotlinx.coroutines.flow.emptyFlow() + override val logRadio = kotlinx.coroutines.flow.emptyFlow() + + override suspend fun sendToRadio(packet: ByteArray) {} + } + // Should not hang — default implementation is a no-op + profile.awaitSubscriptionReady() + } +} diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt new file mode 100644 index 000000000..18c7be4da --- /dev/null +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import com.juul.kable.State +import kotlinx.coroutines.test.TestScope +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** Tests for [toBleConnectionState] and [toDisconnectReason] mappings. */ +class KableStateMappingTest { + + // --- toBleConnectionState --- + + @Test + fun `Connecting maps to BleConnectionState Connecting`() { + val result = State.Connecting.Bluetooth.toBleConnectionState(hasStartedConnecting = false) + assertIs(result) + } + + @Test + fun `Connected maps to BleConnectionState Connected`() { + val scope = TestScope() + val result = State.Connected(scope).toBleConnectionState(hasStartedConnecting = true) + assertIs(result) + } + + @Test + fun `Disconnecting maps to BleConnectionState Disconnecting`() { + val result = State.Disconnecting.toBleConnectionState(hasStartedConnecting = true) + assertIs(result) + } + + @Test + fun `Disconnected before connecting started returns null`() { + val result = State.Disconnected(status = null).toBleConnectionState(hasStartedConnecting = false) + assertNull(result) + } + + @Test + fun `Disconnected after connecting started maps with reason`() { + val result = + State.Disconnected(State.Disconnected.Status.Timeout).toBleConnectionState(hasStartedConnecting = true) + assertIs(result) + assertEquals(DisconnectReason.Timeout, result.reason) + } + + // --- toDisconnectReason --- + + @Test + fun `null status maps to Unknown`() { + assertEquals(DisconnectReason.Unknown, null.toDisconnectReason()) + } + + @Test + fun `CentralDisconnected maps to LocalDisconnect`() { + assertEquals( + DisconnectReason.LocalDisconnect, + State.Disconnected.Status.CentralDisconnected.toDisconnectReason(), + ) + } + + @Test + fun `PeripheralDisconnected maps to RemoteDisconnect`() { + assertEquals( + DisconnectReason.RemoteDisconnect, + State.Disconnected.Status.PeripheralDisconnected.toDisconnectReason(), + ) + } + + @Test + fun `Failed maps to ConnectionFailed`() { + assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.Failed.toDisconnectReason()) + } + + @Test + fun `Timeout maps to Timeout`() { + assertEquals(DisconnectReason.Timeout, State.Disconnected.Status.Timeout.toDisconnectReason()) + } + + @Test + fun `LinkManagerProtocolTimeout maps to Timeout`() { + assertEquals( + DisconnectReason.Timeout, + State.Disconnected.Status.LinkManagerProtocolTimeout.toDisconnectReason(), + ) + } + + @Test + fun `Cancelled maps to Cancelled`() { + assertEquals(DisconnectReason.Cancelled, State.Disconnected.Status.Cancelled.toDisconnectReason()) + } + + @Test + fun `EncryptionTimedOut maps to EncryptionFailed`() { + assertEquals( + DisconnectReason.EncryptionFailed, + State.Disconnected.Status.EncryptionTimedOut.toDisconnectReason(), + ) + } + + @Test + fun `L2CapFailure maps to ConnectionFailed`() { + assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.L2CapFailure.toDisconnectReason()) + } + + @Test + fun `ConnectionLimitReached maps to ConnectionFailed`() { + assertEquals( + DisconnectReason.ConnectionFailed, + State.Disconnected.Status.ConnectionLimitReached.toDisconnectReason(), + ) + } + + @Test + fun `UnknownDevice maps to ConnectionFailed`() { + assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.UnknownDevice.toDisconnectReason()) + } + + @Test + @Suppress("MagicNumber") + fun `Unknown status maps to PlatformSpecific with code`() { + val result = State.Disconnected.Status.Unknown(status = 42).toDisconnectReason() + assertIs(result) + assertEquals(42, result.code) + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index f492dcd65..dc544a300 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -84,7 +84,7 @@ class MeshConfigFlowManagerImpl( * [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed * together by [buildMyNodeInfo] at Stage 1 completion. */ - data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, var metadata: DeviceMetadata? = null) : + data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) : HandshakeState() /** @@ -231,7 +231,7 @@ class MeshConfigFlowManagerImpl( Logger.i { "Local Metadata received: ${metadata.firmware_version}" } val state = handshakeState if (state is HandshakeState.ReceivingConfig) { - state.metadata = metadata + handshakeState = state.copy(metadata = metadata) // Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete, // but the DB write does not need to wait until then. if (metadata != DeviceMetadata()) { diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 1c0d14a01..c3dc2ffd5 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.kermit) implementation(libs.jetbrains.lifecycle.runtime) diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt index f33cedfae..b070ba013 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt @@ -36,14 +36,14 @@ class InterfaceFactory( ) { internal val nopInterface by lazy { nopInterfaceFactory.create("") } - private val specMap: Map> - get() = - mapOf( - InterfaceId.MOCK to mockSpec.value, - InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory), - InterfaceId.SERIAL to serialSpec.value, - InterfaceId.TCP to tcpSpec.value, - ) + private val specMap: Map> by lazy { + mapOf( + InterfaceId.MOCK to mockSpec.value, + InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory), + InterfaceId.SERIAL to serialSpec.value, + InterfaceId.TCP to tcpSpec.value, + ) + } fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest" diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt index e57c4a446..6c843caee 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt @@ -38,19 +38,14 @@ class SerialInterface( connect() } - override fun onDeviceDisconnect(waitForStopped: Boolean) { + override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) { connRef.get()?.close(waitForStopped) - super.onDeviceDisconnect(waitForStopped) + super.onDeviceDisconnect(waitForStopped, isPermanent) } override fun connect() { val deviceMap = usbRepository.serialDevices.value - val device = - if (deviceMap.containsKey(address)) { - deviceMap[address]!! - } else { - deviceMap.map { (_, driver) -> driver }.firstOrNull() - } + val device = deviceMap[address] ?: deviceMap.values.firstOrNull() if (device == null) { Logger.e { "[$address] Serial device not found at address" } } else { diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt index 8597fd060..f510be3bb 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt @@ -33,19 +33,12 @@ class SerialInterfaceSpec( factory.create(rest, service) override fun addressValid(rest: String): Boolean { - usbRepository.serialDevices.value.filterValues { usbManager.hasPermission(it.device) } - findSerial(rest)?.let { d -> - return usbManager.hasPermission(d.device) - } - return false + val driver = findSerial(rest) ?: return false + return usbManager.hasPermission(driver.device) } internal fun findSerial(rest: String): UsbSerialDriver? { val deviceMap = usbRepository.serialDevices.value - return if (deviceMap.containsKey(rest)) { - deviceMap[rest]!! - } else { - deviceMap.map { (_, driver) -> driver }.firstOrNull() - } + return deviceMap[rest] ?: deviceMap.values.firstOrNull() } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt similarity index 50% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt index 5354f5500..cabeb977a 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,17 +14,27 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.network.radio +package org.meshtastic.core.network -import org.meshtastic.core.repository.RadioTransport +import co.touchlab.kermit.Logger +import io.ktor.client.plugins.logging.Logger as KtorLogger /** - * Radio interface factory service provider interface. Each radio backend implementation needs to have a factory to - * create new instances. These instances are specific to a particular address. This interface defines a common API - * across all radio interfaces for obtaining implementation instances. + * Bridges Ktor's HTTP client logging to [Kermit][Logger] so HTTP request/response events appear in the standard app + * logs rather than going to [System.out] via Ktor's default [io.ktor.client.plugins.logging.Logger.DEFAULT]. * - * This is primarily used in conjunction with Dagger assisted injection for each backend interface type. + * Usage: + * ``` + * HttpClient(engine) { + * install(Logging) { + * logger = KermitHttpLogger + * level = LogLevel.HEADERS + * } + * } + * ``` */ -interface InterfaceFactorySpi { - fun create(rest: String): T +object KermitHttpLogger : KtorLogger { + override fun log(message: String) { + Logger.d { message } + } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt index 9942eec87..2eda52102 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt @@ -45,7 +45,9 @@ import org.meshtastic.core.ble.BleDevice import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.BluetoothRepository +import org.meshtastic.core.ble.DisconnectReason import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID +import org.meshtastic.core.ble.classifyBleException import org.meshtastic.core.ble.retryBleOperation import org.meshtastic.core.ble.toMeshtasticRadioProfile import org.meshtastic.core.common.util.nowMillis @@ -57,18 +59,23 @@ import org.meshtastic.proto.ToRadio import kotlin.concurrent.Volatile import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds private const val SCAN_RETRY_COUNT = 3 -private const val SCAN_RETRY_DELAY_MS = 1000L -private const val CONNECTION_TIMEOUT_MS = 15_000L +private val SCAN_RETRY_DELAY = 1.seconds +private val CONNECTION_TIMEOUT = 15.seconds private const val RECONNECT_FAILURE_THRESHOLD = 3 -private const val RECONNECT_BASE_DELAY_MS = 5_000L -private const val RECONNECT_MAX_DELAY_MS = 60_000L +private val RECONNECT_BASE_DELAY = 5.seconds +private val RECONNECT_MAX_DELAY = 60.seconds private const val RECONNECT_MAX_FAILURES = 10 +/** Settle delay before each connection attempt to let the Android BLE stack finish any pending disconnect cleanup. */ +private val SETTLE_DELAY = 1.seconds + /** - * Minimum milliseconds a BLE connection must stay up before we consider it "stable" and reset + * Minimum time a BLE connection must stay up before we consider it "stable" and reset * [BleRadioInterface.consecutiveFailures]. Without this, a device at the edge of BLE range can repeatedly connect for a * fraction of a second and drop — each brief connection resets the failure counter so [RECONNECT_FAILURE_THRESHOLD] is * never reached, and the app never signals [ConnectionState.DeviceSleep]. @@ -76,24 +83,29 @@ private const val RECONNECT_MAX_FAILURES = 10 * The value (5 s) is long enough that only connections that survive past the initial GATT setup are treated as genuine, * but short enough that normal reconnects after light-sleep still reset the counter promptly. */ -private const val MIN_STABLE_CONNECTION_MS = 5_000L +private val MIN_STABLE_CONNECTION = 5.seconds /** - * Returns the reconnect backoff delay in milliseconds for a given consecutive failure count. + * Returns the reconnect backoff delay for a given consecutive failure count. * * Backoff schedule: 1 failure → 5 s 2 failures → 10 s 3 failures → 20 s 4 failures → 40 s 5+ failures → 60 s (capped) */ -internal fun computeReconnectBackoffMs(consecutiveFailures: Int): Long { - if (consecutiveFailures <= 0) return RECONNECT_BASE_DELAY_MS - return minOf(RECONNECT_BASE_DELAY_MS * (1L shl (consecutiveFailures - 1).coerceAtMost(4)), RECONNECT_MAX_DELAY_MS) +internal fun computeReconnectBackoff(consecutiveFailures: Int): Duration { + if (consecutiveFailures <= 0) return RECONNECT_BASE_DELAY + val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(4) + return minOf(RECONNECT_BASE_DELAY * multiplier, RECONNECT_MAX_DELAY) } -// Milliseconds to wait after launching characteristic observations before triggering the -// Meshtastic handshake. Both fromRadio and logRadio observation flows write the CCCD -// asynchronously via Kable's GATT queue. Without this settle window the want_config_id -// burst from the radio can arrive before notifications are enabled, causing the first -// handshake attempt to look like a stall. -private const val CCCD_SETTLE_MS = 50L +/** + * Delay after writing a heartbeat before re-polling FROMRADIO. + * + * The ESP32 firmware processes TORADIO writes asynchronously (NimBLE callback → FreeRTOS main task queue → + * `handleToRadio()` → `heartbeatReceived = true`). The immediate drain trigger in + * [KableMeshtasticRadioProfile.sendToRadio] fires before this completes, so the `queueStatus` response is not yet + * available. 200 ms is well above observed ESP32 task scheduling latency (~10–50 ms) while remaining imperceptible to + * the user. + */ +private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds private val SCAN_TIMEOUT = 5.seconds private val GATT_CLEANUP_TIMEOUT = 5.seconds @@ -120,7 +132,7 @@ class BleRadioInterface( private val bluetoothRepository: BluetoothRepository, private val connectionFactory: BleConnectionFactory, private val service: RadioInterfaceService, - val address: String, + internal val address: String, ) : RadioTransport { private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> @@ -143,11 +155,15 @@ class BleRadioInterface( private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address) private val writeMutex: Mutex = Mutex() - private var connectionStartTime: Long = 0 - private var packetsReceived: Int = 0 - private var packetsSent: Int = 0 - private var bytesReceived: Long = 0 - private var bytesSent: Long = 0 + @Volatile private var connectionStartTime: Long = 0 + + @Volatile private var packetsReceived: Int = 0 + + @Volatile private var packetsSent: Int = 0 + + @Volatile private var bytesReceived: Long = 0 + + @Volatile private var bytesSent: Long = 0 @Volatile private var isFullyConnected = false private var connectionJob: Job? = null @@ -186,7 +202,7 @@ class BleRadioInterface( } if (attempt < SCAN_RETRY_COUNT - 1) { - delay(SCAN_RETRY_DELAY_MS) + delay(SCAN_RETRY_DELAY) } } @@ -199,23 +215,18 @@ class BleRadioInterface( connectionScope.launch { while (isActive) { try { - // Allow any pending background disconnects to complete and the Android BLE stack - // to settle before we attempt a new connection. - @Suppress("MagicNumber") - val connectDelayMs = 1000L - delay(connectDelayMs) + // Settle delay: let the Android BLE stack finish any pending + // disconnect cleanup before starting a new connection attempt. + delay(SETTLE_DELAY) connectionStartTime = nowMillis Logger.i { "[$address] BLE connection attempt started" } val device = findDevice() - // Ensure the device is bonded before connecting. On Android, the - // firmware may require an encrypted link (pairing mode != NO_PIN). - // Without an explicit bond the GATT connection will fail with - // insufficient-authentication (status 5) or the dreaded status 133. - // On Desktop/JVM this is a no-op since the OS handles pairing during - // the GATT connection when the peripheral requires it. + // Bond before connecting: firmware may require an encrypted link, + // and without a bond Android fails with status 5 or 133. + // No-op on Desktop/JVM where the OS handles pairing automatically. if (!bluetoothRepository.isBonded(address)) { Logger.i { "[$address] Device not bonded, initiating bonding" } @Suppress("TooGenericExceptionCaught") @@ -227,36 +238,26 @@ class BleRadioInterface( } } - var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS) - - if (state !is BleConnectionState.Connected) { - // Kable on Android occasionally fails the first connection attempt with - // NotConnectedException if the previous peripheral wasn't fully cleaned - // up by the OS. A quick retry resolves it. - Logger.d { "[$address] First connection attempt failed, retrying in 1.5s" } - @Suppress("MagicNumber") - delay(1500L) - state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS) - } + val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) if (state !is BleConnectionState.Connected) { throw RadioNotConnectedException("Failed to connect to device at address $address") } - // Connection succeeded — only reset the failure counter if the - // connection stays up long enough. See MIN_STABLE_CONNECTION_MS. + // Only reset failures if connection was stable (see MIN_STABLE_CONNECTION). val gattConnectedAt = nowMillis isFullyConnected = true onConnected() - // Use coroutineScope so that the connectionState listener is scoped to this - // iteration only. When the inner scope exits (on disconnect), the listener is - // cancelled automatically before the next reconnect cycle starts a fresh one. + // Scope the connectionState listener to this iteration so it's + // cancelled automatically before the next reconnect cycle. + var disconnectReason: DisconnectReason = DisconnectReason.Unknown coroutineScope { bleConnection.connectionState .onEach { s -> if (s is BleConnectionState.Disconnected && isFullyConnected) { isFullyConnected = false + disconnectReason = s.reason onDisconnected() } } @@ -265,27 +266,30 @@ class BleRadioInterface( discoverServicesAndSetupCharacteristics() - // Suspend here until Kable drops the connection bleConnection.connectionState.first { it is BleConnectionState.Disconnected } } - Logger.i { "[$address] BLE connection dropped, preparing to reconnect" } + Logger.i { + "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" + } - // Only reset the failure counter if the connection was stable (lasted - // longer than MIN_STABLE_CONNECTION_MS). A connection that drops within - // seconds typically means the device is at the edge of BLE range or - // powered off — the Android BLE stack may briefly "connect" to a cached - // GATT profile before realising the device is gone. Without this guard, - // the failure counter resets on every brief connect, preventing us from - // ever reaching RECONNECT_FAILURE_THRESHOLD and signalling DeviceSleep. - val connectionUptime = nowMillis - gattConnectedAt - if (connectionUptime >= MIN_STABLE_CONNECTION_MS) { + // Skip failure counting for intentional disconnects. + if (disconnectReason is DisconnectReason.LocalDisconnect) { + consecutiveFailures = 0 + continue + } + + // A connection that drops almost immediately (< MIN_STABLE_CONNECTION) + // is treated as a failure — the BLE stack may have "connected" to a + // cached GATT profile before realising the device is gone. + val connectionUptime = (nowMillis - gattConnectedAt).milliseconds + if (connectionUptime >= MIN_STABLE_CONNECTION) { consecutiveFailures = 0 } else { consecutiveFailures++ Logger.w { - "[$address] Connection lasted only ${connectionUptime}ms " + - "(< ${MIN_STABLE_CONNECTION_MS}ms) — treating as failure " + + "[$address] Connection lasted only $connectionUptime " + + "(< $MIN_STABLE_CONNECTION) — treating as failure " + "(consecutive failures: $consecutiveFailures)" } if (consecutiveFailures >= RECONNECT_MAX_FAILURES) { @@ -307,16 +311,14 @@ class BleRadioInterface( Logger.d { "[$address] BLE connection coroutine cancelled" } throw e } catch (e: Exception) { - val failureTime = nowMillis - connectionStartTime + val failureTime = (nowMillis - connectionStartTime).milliseconds consecutiveFailures++ Logger.w(e) { - "[$address] Failed to connect to device after ${failureTime}ms " + + "[$address] Failed to connect to device after $failureTime " + "(consecutive failures: $consecutiveFailures)" } - // After exceeding the max failure limit, give up permanently to stop - // draining battery on a device that is genuinely offline. The user - // must manually reconnect from the connections screen. + // Give up permanently to stop draining battery. if (consecutiveFailures >= RECONNECT_MAX_FAILURES) { Logger.e { "[$address] Giving up after $consecutiveFailures consecutive failures" } val (_, msg) = e.toDisconnectReason() @@ -324,18 +326,14 @@ class BleRadioInterface( return@launch } - // At the failure threshold, signal DeviceSleep so - // MeshConnectionManagerImpl can start its sleep timeout. + // Signal DeviceSleep so MeshConnectionManagerImpl starts its sleep timeout. if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) { handleFailure(e) } - // Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s. - // Reduces BLE stack pressure and battery drain when the device is genuinely - // out of range, while still recovering quickly from transient drops. - val backoffMs = computeReconnectBackoffMs(consecutiveFailures) - Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" } - delay(backoffMs) + val backoff = computeReconnectBackoff(consecutiveFailures) + Logger.d { "[$address] Retrying in $backoff (failure #$consecutiveFailures)" } + delay(backoff) } } } @@ -354,23 +352,8 @@ class BleRadioInterface( private fun onDisconnected() { radioService = null - - val uptime = - if (connectionStartTime > 0) { - nowMillis - connectionStartTime - } else { - 0 - } - Logger.i { - "[$address] BLE disconnected - " + - "Uptime: ${uptime}ms, " + - "Packets RX: $packetsReceived ($bytesReceived bytes), " + - "Packets TX: $packetsSent ($bytesSent bytes)" - } - // Signal DeviceSleep immediately so the UI reflects the disconnect while the - // reconnect loop continues in the background. The previous approach suppressed - // this signal until RECONNECT_FAILURE_THRESHOLD consecutive failures, leaving the - // UI stuck on "Connected" for 35+ seconds after the device disappeared. + Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" } + // Signal immediately so the UI reflects the disconnect while reconnect continues. service.onDisconnect(isPermanent = false) } @@ -379,7 +362,6 @@ class BleRadioInterface( bleConnection.profile(serviceUuid = SERVICE_UUID) { service -> val radioService = service.toMeshtasticRadioProfile() - // Wire up notifications radioService.fromRadio .onEach { packet -> Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" } @@ -402,16 +384,12 @@ class BleRadioInterface( } .launchIn(this) - // Store reference for handleSendToRadio this@BleRadioInterface.radioService = radioService Logger.i { "[$address] Profile service active and characteristics subscribed" } - // Give Kable's async CCCD writes time to complete before triggering the - // Meshtastic handshake. The fromRadio/logRadio observation flows register - // notifications through the GATT queue asynchronously. Without this settle - // window, the want_config_id burst arrives before notifications are enabled. - delay(CCCD_SETTLE_MS) + // Wait for FROMNUM CCCD write before triggering the Meshtastic handshake. + radioService.awaitSubscriptionReady() // Log negotiated MTU for diagnostics val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) @@ -421,10 +399,8 @@ class BleRadioInterface( } } catch (e: Exception) { Logger.w(e) { "[$address] Profile service discovery or operation failed" } - // Ensure the peripheral is disconnected so the outer reconnect loop sees a clean - // Disconnected state. Do NOT call handleFailure here — the reconnect loop tracks - // consecutive failures and calls handleFailure after RECONNECT_FAILURE_THRESHOLD, - // preventing premature onDisconnect signals to the service on transient errors. + // Disconnect to let the outer reconnect loop see a clean Disconnected state. + // Do NOT call handleFailure here — the reconnect loop owns failure counting. try { bleConnection.disconnect() } catch (ignored: Exception) { @@ -481,25 +457,25 @@ class BleRadioInterface( val nonce = heartbeatNonce.fetchAndAdd(1) Logger.v { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" } handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode()) + + // The firmware responds to heartbeats by queuing a `queueStatus` FromRadio packet + // on the next getFromRadio() call, but it does NOT send a FROMNUM notification for + // it. The immediate drain trigger in sendToRadio() fires before the ESP32's async + // task queue has processed the heartbeat, so the response sits unread. Schedule a + // delayed re-drain to pick it up. + connectionScope.launch { + delay(HEARTBEAT_DRAIN_DELAY) + radioService?.requestDrain() + } } /** Closes the connection to the device. */ override fun close() { - val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0 - Logger.i { - "[$address] Disconnecting. " + - "Uptime: ${uptime}ms, " + - "Packets RX: $packetsReceived ($bytesReceived bytes), " + - "Packets TX: $packetsSent ($bytesSent bytes)" - } - // Cancel the connection scope to break the while(isActive) reconnect loop. + Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" } connectionScope.cancel("close() called") - // GATT cleanup must survive serviceScope cancellation. SharedRadioInterfaceService calls - // close() and then immediately cancels serviceScope — a coroutine launched on serviceScope - // may never be dispatched, leaving the BluetoothGatt object leaked (causes GATT 133 on the - // next connect attempt). GlobalScope is the correct tool here: the cleanup is short-lived, - // fire-and-forget, and must outlive any application-managed scope. - // onDisconnect is handled by SharedRadioInterfaceService.stopInterfaceLocked() directly. + // GATT cleanup must outlive serviceScope cancellation — GlobalScope is intentional. + // SharedRadioInterfaceService cancels serviceScope immediately after close(), so a + // coroutine launched there may never run, leaking BluetoothGatt (causes GATT 133). @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch { try { @@ -525,17 +501,27 @@ class BleRadioInterface( service.onDisconnect(isPermanent, errorMessage = msg) } + /** Formats a one-line session statistics summary for logging. */ + private fun formatSessionStats(): String { + val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0 + return "Uptime: ${uptime}ms, " + + "Packets RX: $packetsReceived ($bytesReceived bytes), " + + "Packets TX: $packetsSent ($bytesSent bytes)" + } + private fun Throwable.toDisconnectReason(): Pair { - val isPermanent = - this::class.simpleName == "BluetoothUnavailableException" || - this::class.simpleName == "ManagerClosedException" + classifyBleException()?.let { + return it.isPermanent to it.message + } + val msg = - when { - this is RadioNotConnectedException -> this.message ?: "Device not found" - this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing" - this::class.simpleName == "GattException" -> "GATT Error: ${this.message}" + when (this) { + is RadioNotConnectedException -> this.message ?: "Device not found" + is NoSuchElementException, + is IllegalArgumentException, + -> "Required characteristic missing" else -> this.message ?: this::class.simpleName ?: "Unknown" } - return Pair(isPermanent, msg) + return false to msg } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt index ea985c020..d72c9d0d5 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt @@ -42,11 +42,11 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) : R * * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the * manager callbacks + * @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g. + * TCP transient disconnect). Defaults to true for serial — subclasses like [TCPInterface] override with false. */ - protected open fun onDeviceDisconnect(waitForStopped: Boolean) { - service.onDisconnect( - isPermanent = true, - ) // if USB device disconnects it is definitely permanently gone, not sleeping) + protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) { + service.onDisconnect(isPermanent = isPermanent) } protected open fun connect() { diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index 41fb652ed..56d70d453 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.model.util.subscribeList import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.proto.MqttClientProxyMessage +import kotlin.concurrent.Volatile @Single(binds = [MQTTRepository::class]) class MQTTRepositoryImpl( @@ -62,7 +63,7 @@ class MQTTRepositoryImpl( private const val RECONNECT_BACKOFF_MULTIPLIER = 2 } - private var client: MQTTClient? = null + @Volatile private var client: MQTTClient? = null @OptIn(ExperimentalSerializationApi::class) private val json = Json { @@ -70,7 +71,8 @@ class MQTTRepositoryImpl( exceptionsWithDebugInfo = false } private val scope = CoroutineScope(dispatchers.default + SupervisorJob()) - private var clientJob: Job? = null + + @Volatile private var clientJob: Job? = null private val publishSemaphore = Semaphore(20) @Suppress("TooGenericExceptionCaught") @@ -149,12 +151,10 @@ class MQTTRepositoryImpl( while (true) { try { Logger.i { "MQTT Starting client loop for $host:$port" } - // Reset backoff on each successful connection establishment. If the broker - // disconnects cleanly after hours of operation, the next reconnect should - // start with the minimum delay rather than whatever was accumulated. - reconnectDelay = INITIAL_RECONNECT_DELAY_MS newClient.runSuspend() - // runSuspend returned normally — broker closed connection. Retry. + // runSuspend returned normally — broker closed connection cleanly. + // Reset backoff so the next reconnect starts with the minimum delay. + reconnectDelay = INITIAL_RECONNECT_DELAY_MS Logger.w { "MQTT client loop ended normally, reconnecting in ${reconnectDelay}ms" } } catch (e: io.github.davidepianca98.mqtt.MQTTException) { Logger.e(e) { "MQTT Client loop error (MQTT), reconnecting in ${reconnectDelay}ms" } @@ -199,15 +199,25 @@ class MQTTRepositoryImpl( @OptIn(ExperimentalUnsignedTypes::class) override fun publish(topic: String, data: ByteArray, retained: Boolean) { + val currentClient = client + if (currentClient == null) { + Logger.w { "MQTT publish to $topic dropped: client not connected" } + return + } Logger.d { "MQTT publishing message to topic $topic (size: ${data.size} bytes, retained: $retained)" } scope.launch { publishSemaphore.withPermit { - client?.publish( - retain = retained, - qos = Qos.AT_LEAST_ONCE, - topic = topic, - payload = data.toUByteArray(), - ) + @Suppress("TooGenericExceptionCaught") + try { + currentClient.publish( + retain = retained, + qos = Qos.AT_LEAST_ONCE, + topic = topic, + payload = data.toUByteArray(), + ) + } catch (e: Exception) { + Logger.w(e) { "MQTT publish to $topic failed" } + } } } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt index d4fd0dcc1..d4a41ba95 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt @@ -36,6 +36,7 @@ import org.meshtastic.core.testing.FakeBluetoothRepository import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class BleRadioInterfaceTest { @@ -164,14 +165,14 @@ class BleRadioInterfaceTest { } @Test - fun `computeReconnectBackoffMs returns correct backoff values`() { - assertEquals(5_000L, computeReconnectBackoffMs(0)) - assertEquals(5_000L, computeReconnectBackoffMs(1)) - assertEquals(10_000L, computeReconnectBackoffMs(2)) - assertEquals(20_000L, computeReconnectBackoffMs(3)) - assertEquals(40_000L, computeReconnectBackoffMs(4)) - assertEquals(60_000L, computeReconnectBackoffMs(5)) - assertEquals(60_000L, computeReconnectBackoffMs(10)) - assertEquals(60_000L, computeReconnectBackoffMs(100)) + fun `computeReconnectBackoff returns correct backoff values`() { + assertEquals(5.seconds, computeReconnectBackoff(0)) + assertEquals(5.seconds, computeReconnectBackoff(1)) + assertEquals(10.seconds, computeReconnectBackoff(2)) + assertEquals(20.seconds, computeReconnectBackoff(3)) + assertEquals(40.seconds, computeReconnectBackoff(4)) + assertEquals(60.seconds, computeReconnectBackoff(5)) + assertEquals(60.seconds, computeReconnectBackoff(10)) + assertEquals(60.seconds, computeReconnectBackoff(100)) } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt index 007b82b45..c4e64d36a 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.network.radio import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds /** * Tests the exponential backoff schedule used by [BleRadioInterface] when consecutive connection attempts fail. The @@ -28,42 +29,42 @@ class ReconnectBackoffTest { @Test fun `zero failures yields base delay`() { - assertEquals(5_000L, computeReconnectBackoffMs(0)) + assertEquals(5.seconds, computeReconnectBackoff(0)) } @Test fun `first failure yields 5s`() { - assertEquals(5_000L, computeReconnectBackoffMs(1)) + assertEquals(5.seconds, computeReconnectBackoff(1)) } @Test fun `second failure yields 10s`() { - assertEquals(10_000L, computeReconnectBackoffMs(2)) + assertEquals(10.seconds, computeReconnectBackoff(2)) } @Test fun `third failure yields 20s`() { - assertEquals(20_000L, computeReconnectBackoffMs(3)) + assertEquals(20.seconds, computeReconnectBackoff(3)) } @Test fun `fourth failure yields 40s`() { - assertEquals(40_000L, computeReconnectBackoffMs(4)) + assertEquals(40.seconds, computeReconnectBackoff(4)) } @Test fun `fifth failure is capped at 60s`() { - assertEquals(60_000L, computeReconnectBackoffMs(5)) + assertEquals(60.seconds, computeReconnectBackoff(5)) } @Test fun `large failure count stays capped at 60s`() { - assertEquals(60_000L, computeReconnectBackoffMs(100)) + assertEquals(60.seconds, computeReconnectBackoff(100)) } @Test fun `backoff is strictly increasing up to the cap`() { - val values = (1..5).map { computeReconnectBackoffMs(it) } + val values = (1..5).map { computeReconnectBackoff(it) } for (i in 0 until values.size - 1) { assertTrue( values[i] < values[i + 1], diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt index adab96d4d..0ffb731cf 100644 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt @@ -52,7 +52,8 @@ open class TCPInterface( override fun onDisconnected() { // Transport already performed teardown; only propagate lifecycle to StreamInterface. - super@TCPInterface.onDeviceDisconnect(false) + // TCP disconnects are transient (not permanent) — the transport will auto-reconnect. + super@TCPInterface.onDeviceDisconnect(false, isPermanent = false) } override fun onPacketReceived(bytes: ByteArray) { @@ -71,9 +72,9 @@ open class TCPInterface( Logger.d { "[$address] TCPInterface.sendBytes delegated to transport" } } - override fun onDeviceDisconnect(waitForStopped: Boolean) { + override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) { transport.stop() - super.onDeviceDisconnect(waitForStopped) + super.onDeviceDisconnect(waitForStopped, isPermanent = false) } override fun connect() { diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt index dcc0a402f..264e42f89 100644 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt @@ -65,6 +65,10 @@ class TcpTransport( } companion object { + /** + * Maximum reconnect retries. Set to [Int.MAX_VALUE] to retry indefinitely — the caller ([TcpTransport.stop]) + * owns the cancellation lifecycle. + */ const val MAX_RECONNECT_RETRIES = Int.MAX_VALUE const val MIN_BACKOFF_MILLIS = 1_000L const val MAX_BACKOFF_MILLIS = 5 * 60 * 1_000L @@ -84,18 +88,26 @@ class TcpTransport( ) // TCP socket state - private var socket: Socket? = null - private var outStream: OutputStream? = null - private var connectionJob: Job? = null - private var currentAddress: String? = null + @Volatile private var socket: Socket? = null + + @Volatile private var outStream: OutputStream? = null + + @Volatile private var connectionJob: Job? = null + + @Volatile private var currentAddress: String? = null // Metrics - private var connectionStartTime: Long = 0 - private var packetsReceived: Int = 0 - private var packetsSent: Int = 0 - private var bytesReceived: Long = 0 - private var bytesSent: Long = 0 - private var timeoutEvents: Int = 0 + @Volatile private var connectionStartTime: Long = 0 + + @Volatile private var packetsReceived: Int = 0 + + @Volatile private var packetsSent: Int = 0 + + @Volatile private var bytesReceived: Long = 0 + + @Volatile private var bytesSent: Long = 0 + + @Volatile private var timeoutEvents: Int = 0 /** Whether the transport is currently connected. */ val isConnected: Boolean diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt index 27dc3facc..e5280ec45 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt @@ -42,7 +42,7 @@ import kotlin.uuid.Uuid class FakeBleDevice( override val address: String, override val name: String? = "Fake Device", - initialState: BleConnectionState = BleConnectionState.Disconnected, + initialState: BleConnectionState = BleConnectionState.Disconnected(), ) : BaseFake(), BleDevice { private val _state = mutableStateFlow(initialState) @@ -124,11 +124,11 @@ class FakeBleConnection : } } - override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState { + override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState { connectException?.let { throw it } if (failNextN > 0) { failNextN-- - return BleConnectionState.Disconnected + return BleConnectionState.Disconnected() } connect(device) return BleConnectionState.Connected @@ -137,9 +137,9 @@ class FakeBleConnection : override suspend fun disconnect() { disconnectCalls++ val currentDevice = _device.value - _connectionState.emit(BleConnectionState.Disconnected) + _connectionState.emit(BleConnectionState.Disconnected()) if (currentDevice is FakeBleDevice) { - currentDevice.setState(BleConnectionState.Disconnected) + currentDevice.setState(BleConnectionState.Disconnected()) } _device.value = null _deviceFlow.emit(null) diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index bcaab0590..14075fbda 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -285,6 +285,7 @@ dependencies { // Ktor HttpClient (Java engine for JVM/Desktop) implementation(libs.ktor.client.java) implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.androidx.paging.common) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index b93c16a75..978be6b26 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -20,6 +20,8 @@ package org.meshtastic.desktop.di import io.ktor.client.HttpClient import io.ktor.client.engine.java.Java import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import org.koin.dsl.module @@ -30,6 +32,7 @@ import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.RadioController +import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.LocationRepository @@ -168,7 +171,17 @@ private fun desktopPlatformStubsModule() = module { } // Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android) - single { HttpClient(Java) { install(ContentNegotiation) { json(get()) } } } + single { + HttpClient(Java) { + install(ContentNegotiation) { json(get()) } + if (org.meshtastic.desktop.DesktopBuildConfig.IS_DEBUG) { + install(Logging) { + logger = KermitHttpLogger + level = LogLevel.HEADERS + } + } + } + } // Desktop stubs for data sources that load from Android assets on mobile single { diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt index 9d2478f45..3bdb0f1d7 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt @@ -38,6 +38,9 @@ import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** BLE transport implementation for ESP32 Unified OTA protocol using Kable. */ class BleOtaTransport( @@ -68,7 +71,7 @@ class BleOtaTransport( tag = "BLE OTA", serviceUuid = OTA_SERVICE_UUID, retryCount = SCAN_RETRY_COUNT, - retryDelayMs = SCAN_RETRY_DELAY_MS, + retryDelay = SCAN_RETRY_DELAY, ) { it.address in targetAddresses } @@ -76,8 +79,8 @@ class BleOtaTransport( @Suppress("MagicNumber") override suspend fun connect(): Result = runCatching { - Logger.i { "BLE OTA: Waiting ${REBOOT_DELAY_MS}ms for device to reboot into OTA mode..." } - delay(REBOOT_DELAY_MS) + Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." } + delay(REBOOT_DELAY) Logger.i { "BLE OTA: Connecting to $address using Kable..." } @@ -96,7 +99,7 @@ class BleOtaTransport( .launchIn(transportScope) try { - val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS) + val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) if (finalState is BleConnectionState.Disconnected) { Logger.w { "BLE OTA: Failed to connect to ${device.address} (state=$finalState)" } throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${device.address}") @@ -137,7 +140,7 @@ class BleOtaTransport( .launchIn(this) // Allow time for the BLE subscription to be established before proceeding. - delay(SUBSCRIPTION_SETTLE_MS) + delay(SUBSCRIPTION_SETTLE) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -156,7 +159,7 @@ class BleOtaTransport( var handshakeComplete = false var responsesReceived = 0 while (!handshakeComplete) { - val response = waitForResponse(ERASING_TIMEOUT_MS) + val response = waitForResponse(ERASING_TIMEOUT) responsesReceived++ when (val parsed = OtaResponse.parse(response)) { is OtaResponse.Ok -> { @@ -203,7 +206,7 @@ class BleOtaTransport( val nextSentBytes = sentBytes + currentChunkSize repeat(packetsSentForChunk) { i -> - val response = waitForResponse(ACK_TIMEOUT_MS) + val response = waitForResponse(ACK_TIMEOUT) val isLastPacketOfChunk = i == packetsSentForChunk - 1 when (val parsed = OtaResponse.parse(response)) { @@ -229,7 +232,7 @@ class BleOtaTransport( onProgress(sentBytes.toFloat() / totalBytes) } - val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS) + val finalResponse = waitForResponse(VERIFICATION_TIMEOUT) when (val parsed = OtaResponse.parse(finalResponse)) { is OtaResponse.Ok -> Unit is OtaResponse.Error -> { @@ -274,21 +277,21 @@ class BleOtaTransport( return packetsSent } - private suspend fun waitForResponse(timeoutMs: Long): String = try { - withTimeout(timeoutMs) { responseChannel.receive() } + private suspend fun waitForResponse(timeout: Duration): String = try { + withTimeout(timeout) { responseChannel.receive() } } catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) { - throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms") + throw OtaProtocolException.Timeout("Timeout waiting for response after $timeout") } companion object { - private const val CONNECTION_TIMEOUT_MS = 15_000L - private const val SUBSCRIPTION_SETTLE_MS = 500L - private const val ERASING_TIMEOUT_MS = 60_000L - private const val ACK_TIMEOUT_MS = 10_000L - private const val VERIFICATION_TIMEOUT_MS = 10_000L - private const val REBOOT_DELAY_MS = 5_000L + private val CONNECTION_TIMEOUT = 15.seconds + private val SUBSCRIPTION_SETTLE = 500.milliseconds + private val ERASING_TIMEOUT = 60.seconds + private val ACK_TIMEOUT = 10.seconds + private val VERIFICATION_TIMEOUT = 10.seconds + private val REBOOT_DELAY = 5.seconds private const val SCAN_RETRY_COUNT = 3 - private const val SCAN_RETRY_DELAY_MS = 2_000L + private val SCAN_RETRY_DELAY = 2.seconds const val RECOMMENDED_CHUNK_SIZE = 512 } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt index 6df54ea43..97fced4c6 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt @@ -26,7 +26,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds internal const val DEFAULT_SCAN_RETRY_COUNT = 3 -internal const val DEFAULT_SCAN_RETRY_DELAY_MS = 2_000L +internal val DEFAULT_SCAN_RETRY_DELAY: Duration = 2.seconds internal val DEFAULT_SCAN_TIMEOUT: Duration = 10.seconds private const val MAC_PARTS_COUNT = 6 @@ -59,7 +59,7 @@ internal suspend fun scanForBleDevice( tag: String, serviceUuid: kotlin.uuid.Uuid, retryCount: Int = DEFAULT_SCAN_RETRY_COUNT, - retryDelayMs: Long = DEFAULT_SCAN_RETRY_DELAY_MS, + retryDelay: Duration = DEFAULT_SCAN_RETRY_DELAY, scanTimeout: Duration = DEFAULT_SCAN_TIMEOUT, predicate: (BleDevice) -> Boolean, ): BleDevice? { @@ -80,7 +80,7 @@ internal suspend fun scanForBleDevice( return device } Logger.w { "$tag: Target not in ${foundDevices.size} devices found" } - if (attempt < retryCount - 1) delay(retryDelayMs) + if (attempt < retryCount - 1) delay(retryDelay) } return null } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index f3d9d8648..83d0deecc 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -48,6 +48,9 @@ import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH import org.meshtastic.feature.firmware.ota.calculateMacPlusOne import org.meshtastic.feature.firmware.ota.scanForBleDevice +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** * Kable-based transport for the Nordic Secure DFU (Secure DFU over BLE) protocol. @@ -96,7 +99,7 @@ class SecureDfuTransport( ?: throw DfuException.ConnectionFailed("Device $address not found for buttonless DFU trigger") Logger.i { "DFU: Connecting to $address to trigger buttonless DFU..." } - bleConnection.connectAndAwait(device, CONNECT_TIMEOUT_MS) + bleConnection.connectAndAwait(device, CONNECT_TIMEOUT) bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val buttonlessChar = service.characteristic(SecureDfuUuids.BUTTONLESS_NO_BONDS) @@ -111,7 +114,7 @@ class SecureDfuTransport( .catch { e -> Logger.d(e) { "DFU: Buttonless indication stream ended (expected on disconnect)" } } .launchIn(this) - delay(SUBSCRIPTION_SETTLE_MS) + delay(SUBSCRIPTION_SETTLE) Logger.i { "DFU: Writing buttonless DFU trigger..." } service.write(buttonlessChar, byteArrayOf(0x01), BleWriteType.WITH_RESPONSE) @@ -119,7 +122,7 @@ class SecureDfuTransport( // Wait for the indication response (0x20-01-STATUS). The device may disconnect before we receive it — // that's expected and treated as success, matching the Nordic DFU library's behavior. try { - withTimeout(BUTTONLESS_RESPONSE_TIMEOUT_MS) { + withTimeout(BUTTONLESS_RESPONSE_TIMEOUT) { val response = indicationChannel.receive() if (response.size >= 3 && response[0] == BUTTONLESS_RESPONSE_CODE && response[2] != 0x01.toByte()) { Logger.w { "DFU: Buttonless DFU response indicates error: ${response.toHexString()}" } @@ -162,7 +165,7 @@ class SecureDfuTransport( bleConnection.connectionState.onEach { Logger.d { "DFU: Connection state → $it" } }.launchIn(transportScope) - val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT_MS) + val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT) if (connected is BleConnectionState.Disconnected) { throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address}") } @@ -188,7 +191,7 @@ class SecureDfuTransport( } .launchIn(this) - delay(SUBSCRIPTION_SETTLE_MS) + delay(SUBSCRIPTION_SETTLE) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -286,7 +289,7 @@ class SecureDfuTransport( } catch (e: Throwable) { lastError = e Logger.w(e) { "DFU: Object transfer failed (attempt ${attempt + 1}/$OBJECT_RETRY_COUNT): ${e.message}" } - if (attempt < OBJECT_RETRY_COUNT - 1) delay(RETRY_DELAY_MS) + if (attempt < OBJECT_RETRY_COUNT - 1) delay(RETRY_DELAY) } } throw lastError ?: DfuException.TransferFailed("Object transfer failed after $OBJECT_RETRY_COUNT attempts") @@ -347,7 +350,7 @@ class SecureDfuTransport( // First-chunk delay: some older bootloaders need time to prepare flash after Create. // The Nordic DFU library uses 400ms for the first chunk. if (isFirstChunk) { - delay(FIRST_CHUNK_DELAY_MS) + delay(FIRST_CHUNK_DELAY) isFirstChunk = false } @@ -399,7 +402,7 @@ class SecureDfuTransport( } catch (e: DfuException.ProtocolError) { if (e.resultCode == DfuResultCode.INVALID_OBJECT && offset + objectSize >= totalBytes) { Logger.w { "DFU: Execute returned INVALID_OBJECT on final object, retrying once..." } - delay(RETRY_DELAY_MS) + delay(RETRY_DELAY) sendExecute() } else { throw e @@ -440,7 +443,7 @@ class SecureDfuTransport( // Wait for the device's PRN receipt notification, then validate CRC. // Skip the wait on the last packet — the final CALCULATE_CHECKSUM covers it. if (prnInterval > 0 && packetsSincePrn >= prnInterval && pos < until) { - val response = awaitNotification(COMMAND_TIMEOUT_MS) + val response = awaitNotification(COMMAND_TIMEOUT) if (response is DfuResponse.ChecksumResult) { val expectedCrc = DfuCrc32.calculate(data, length = pos) if (response.offset != pos || response.crc32 != expectedCrc) { @@ -459,7 +462,7 @@ class SecureDfuTransport( val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) service.write(controlChar, payload, BleWriteType.WITH_RESPONSE) } - return awaitNotification(COMMAND_TIMEOUT_MS) + return awaitNotification(COMMAND_TIMEOUT) } private suspend fun setPrn(value: Int) { @@ -506,13 +509,13 @@ class SecureDfuTransport( Logger.d { "DFU: Object executed." } } - private suspend fun awaitNotification(timeoutMs: Long): DfuResponse = try { - withTimeout(timeoutMs) { + private suspend fun awaitNotification(timeout: Duration): DfuResponse = try { + withTimeout(timeout) { val bytes = notificationChannel.receive() DfuResponse.parse(bytes).also { Logger.d { "DFU: Notification → $it" } } } } catch (_: TimeoutCancellationException) { - throw DfuException.Timeout("No response from Control Point after ${timeoutMs}ms") + throw DfuException.Timeout("No response from Control Point after $timeout") } private fun DfuResponse.requireSuccess(expectedOpcode: Byte) { @@ -541,7 +544,7 @@ class SecureDfuTransport( tag = "DFU", serviceUuid = SecureDfuUuids.SERVICE, retryCount = SCAN_RETRY_COUNT, - retryDelayMs = SCAN_RETRY_DELAY_MS, + retryDelay = SCAN_RETRY_DELAY, predicate = predicate, ) @@ -550,14 +553,14 @@ class SecureDfuTransport( // --------------------------------------------------------------------------- companion object { - private const val CONNECT_TIMEOUT_MS = 15_000L - private const val COMMAND_TIMEOUT_MS = 30_000L - private const val SUBSCRIPTION_SETTLE_MS = 500L - private const val BUTTONLESS_RESPONSE_TIMEOUT_MS = 3_000L + private val CONNECT_TIMEOUT = 15.seconds + private val COMMAND_TIMEOUT = 30.seconds + private val SUBSCRIPTION_SETTLE = 500.milliseconds + private val BUTTONLESS_RESPONSE_TIMEOUT = 3.seconds private const val SCAN_RETRY_COUNT = 3 - private const val SCAN_RETRY_DELAY_MS = 2_000L - private const val RETRY_DELAY_MS = 2_000L - private const val FIRST_CHUNK_DELAY_MS = 400L + private val SCAN_RETRY_DELAY = 2.seconds + private val RETRY_DELAY = 2.seconds + private val FIRST_CHUNK_DELAY = 400.milliseconds /** Response code prefix for Buttonless DFU indications (0x20 = response). */ private const val BUTTONLESS_RESPONSE_CODE: Byte = 0x20 diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt index b6a73bc52..da8f84057 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt @@ -614,8 +614,8 @@ class SecureDfuTransportTest { override suspend fun connect(device: BleDevice) = delegate.connect(device) - override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long) = - delegate.connectAndAwait(device, timeoutMs) + override suspend fun connectAndAwait(device: BleDevice, timeout: Duration) = + delegate.connectAndAwait(device, timeout) override suspend fun disconnect() = delegate.disconnect() diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt index f174d5746..5b0d8398c 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.feature.wifiprovision +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid /** @@ -62,14 +64,14 @@ internal object NymeaBleConstants { /** JSON stream terminator — marks the end of a reassembled message. */ const val STREAM_TERMINATOR = '\n' - /** Scan + connect timeout in milliseconds. */ - const val SCAN_TIMEOUT_MS = 10_000L + /** Scan + connect timeout. */ + val SCAN_TIMEOUT = 10.seconds /** Maximum time to wait for a command response. */ - const val RESPONSE_TIMEOUT_MS = 15_000L + val RESPONSE_TIMEOUT = 15.seconds /** Settle time after subscribing to notifications before sending commands. */ - const val SUBSCRIPTION_SETTLE_MS = 300L + val SUBSCRIPTION_SETTLE = 300.milliseconds // endregion // region Wireless Commander command codes diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt index 067dec798..03330dc3e 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt @@ -43,14 +43,13 @@ import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_GET_NETWORKS import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_SCAN import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_SUCCESS -import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_TIMEOUT_MS -import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SCAN_TIMEOUT_MS -import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SUBSCRIPTION_SETTLE_MS +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_TIMEOUT +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SCAN_TIMEOUT +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SUBSCRIPTION_SETTLE import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_COMMANDER_UUID import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_SERVICE_UUID import org.meshtastic.feature.wifiprovision.model.ProvisionResult import org.meshtastic.feature.wifiprovision.model.WifiNetwork -import kotlin.time.Duration.Companion.milliseconds /** * GATT client for the nymea-networkmanager WiFi provisioning profile. @@ -87,26 +86,20 @@ class NymeaWifiService( * * @param address Optional MAC address filter. If null, the first advertising device is used. * @return The discovered device's advertised name on success. - * @throws IllegalStateException if no device is found within [SCAN_TIMEOUT_MS]. + * @throws IllegalStateException if no device is found within [SCAN_TIMEOUT]. */ suspend fun connect(address: String? = null): Result = runCatching { Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" } val device = - withTimeout(SCAN_TIMEOUT_MS) { - scanner - .scan( - timeout = SCAN_TIMEOUT_MS.milliseconds, - serviceUuid = WIRELESS_SERVICE_UUID, - address = address, - ) - .first() + withTimeout(SCAN_TIMEOUT) { + scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = WIRELESS_SERVICE_UUID, address = address).first() } val deviceName = device.name ?: device.address Logger.i { "$TAG: Found device: ${device.name} @ ${device.address}" } - val state = bleConnection.connectAndAwait(device, SCAN_TIMEOUT_MS) + val state = bleConnection.connectAndAwait(device, SCAN_TIMEOUT) check(state is BleConnectionState.Connected) { "Failed to connect to ${device.address} — final state: $state" } Logger.i { "$TAG: Connected. Discovering wireless service…" } @@ -130,7 +123,7 @@ class NymeaWifiService( } .launchIn(this) - delay(SUBSCRIPTION_SETTLE_MS) + delay(SUBSCRIPTION_SETTLE) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -235,8 +228,8 @@ class NymeaWifiService( } } - /** Wait up to [RESPONSE_TIMEOUT_MS] for a complete JSON response from the notification channel. */ - private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT_MS) { responseChannel.receive() } + /** Wait up to [RESPONSE_TIMEOUT] for a complete JSON response from the notification channel. */ + private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT) { responseChannel.receive() } private fun nymeaErrorMessage(code: Int): String = when (code) { NymeaBleConstants.RESPONSE_INVALID_COMMAND -> "Invalid command" From 172680fd46c4fc8a2c8fe2e7c6e935d3ace2da9c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:38:33 -0500 Subject: [PATCH 012/114] fix(mqtt): replace yield() with proper connection readiness signal (#5073) --- .../network/repository/MQTTRepositoryImpl.kt | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index 56d70d453..6be47c8eb 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -145,6 +145,30 @@ class MQTTRepositoryImpl( client = newClient + // Subscribe before starting the event loop. KMQTT's subscribe() calls send(), + // which queues the SUBSCRIBE packet in pendingSendMessages while connackReceived + // is false. Once the event loop receives CONNACK, it flushes the queue — so + // subscriptions are guaranteed to be sent immediately after the connection is + // established, with no timing races. This replaces a previous yield()-based + // approach that was unreliable on lightly loaded dispatchers. + val subscriptions = mutableListOf() + channelSet.subscribeList.forEach { globalId -> + subscriptions.add( + Subscription("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), + ) + if (mqttConfig?.json_enabled == true) { + subscriptions.add( + Subscription("$rootTopic$JSON_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), + ) + } + } + subscriptions.add(Subscription("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+", SubscriptionOptions(Qos.AT_LEAST_ONCE))) + + if (subscriptions.isNotEmpty()) { + Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } + newClient.subscribe(subscriptions) + } + clientJob = scope.launch { var reconnectDelay = INITIAL_RECONNECT_DELAY_MS @@ -170,30 +194,6 @@ class MQTTRepositoryImpl( } } - // Subscriptions: placed after runSuspend is launched and has had time to establish - // the TCP connection. KMQTT's subscribe() queues internally, but subscribing before - // the connection is ready may silently drop subscriptions depending on the version. - // A brief yield gives runSuspend() time to connect before we subscribe. - kotlinx.coroutines.yield() - - val subscriptions = mutableListOf() - channelSet.subscribeList.forEach { globalId -> - subscriptions.add( - Subscription("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), - ) - if (mqttConfig?.json_enabled == true) { - subscriptions.add( - Subscription("$rootTopic$JSON_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), - ) - } - } - subscriptions.add(Subscription("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+", SubscriptionOptions(Qos.AT_LEAST_ONCE))) - - if (subscriptions.isNotEmpty()) { - Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } - newClient.subscribe(subscriptions) - } - awaitClose { disconnect() } } From 174315b21f5affcb952642f1650d316c86b8a40f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:39:29 -0500 Subject: [PATCH 013/114] refactor(data): replace lateinit var scope + start() with constructor injection (#5075) --- .../core/data/manager/CommandSenderImpl.kt | 6 +- .../data/manager/MeshActionHandlerImpl.kt | 7 +- .../data/manager/MeshConfigFlowManagerImpl.kt | 7 +- .../data/manager/MeshConfigHandlerImpl.kt | 6 +- .../data/manager/MeshConnectionManagerImpl.kt | 11 +-- .../core/data/manager/MeshDataHandlerImpl.kt | 9 +- .../data/manager/MeshMessageProcessorImpl.kt | 6 +- .../core/data/manager/MeshRouterImpl.kt | 10 -- .../core/data/manager/MqttManagerImpl.kt | 6 +- .../data/manager/NeighborInfoHandlerImpl.kt | 6 -- .../core/data/manager/NodeManagerImpl.kt | 7 +- .../core/data/manager/PacketHandlerImpl.kt | 8 +- .../manager/StoreForwardPacketHandlerImpl.kt | 7 +- .../manager/TelemetryPacketHandlerImpl.kt | 7 +- .../data/manager/TracerouteHandlerImpl.kt | 7 +- .../data/manager/MeshActionHandlerImplTest.kt | 99 ++++++++++--------- .../manager/MeshConfigFlowManagerImplTest.kt | 2 +- .../data/manager/MeshConfigHandlerImplTest.kt | 37 +++---- .../manager/MeshConnectionManagerImplTest.kt | 64 ++++++------ .../core/data/manager/MeshDataHandlerTest.kt | 2 +- .../manager/MeshMessageProcessorImplTest.kt | 47 ++++----- .../core/data/manager/NodeManagerImplTest.kt | 4 +- .../data/manager/PacketHandlerImplTest.kt | 2 +- .../StoreForwardPacketHandlerImplTest.kt | 2 +- .../manager/TelemetryPacketHandlerImplTest.kt | 2 +- .../core/repository/CommandSender.kt | 4 - .../core/repository/MeshActionHandler.kt | 4 - .../core/repository/MeshConfigFlowManager.kt | 4 - .../core/repository/MeshConfigHandler.kt | 4 - .../core/repository/MeshConnectionManager.kt | 4 - .../core/repository/MeshDataHandler.kt | 4 - .../core/repository/MeshMessageProcessor.kt | 4 - .../meshtastic/core/repository/MeshRouter.kt | 5 - .../meshtastic/core/repository/MqttManager.kt | 5 +- .../core/repository/NeighborInfoHandler.kt | 4 - .../meshtastic/core/repository/NodeManager.kt | 4 - .../core/repository/PacketHandler.kt | 4 - .../repository/StoreForwardPacketHandler.kt | 4 - .../core/repository/TelemetryPacketHandler.kt | 4 - .../core/repository/TracerouteHandler.kt | 4 - .../core/service/MeshServiceOrchestrator.kt | 29 ++---- .../core/service/di/CoreServiceModule.kt | 12 ++- .../service/MeshServiceOrchestratorTest.kt | 15 +-- 43 files changed, 188 insertions(+), 301 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index c26dc0f5f..ca22f927d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import okio.ByteString import okio.ByteString.Companion.toByteString +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.DataPacket @@ -59,8 +60,8 @@ class CommandSenderImpl( private val radioConfigRepository: RadioConfigRepository, private val tracerouteHandler: TracerouteHandler, private val neighborInfoHandler: NeighborInfoHandler, + @Named("ServiceScope") private val scope: CoroutineScope, ) : CommandSender { - private lateinit var scope: CoroutineScope private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue) private val sessionPasskey = atomic(ByteString.EMPTY) @@ -71,8 +72,7 @@ class CommandSenderImpl( // maybe via ServiceRepository or similar. // For now I'll assume it's injected or available. - override fun start(scope: CoroutineScope) { - this.scope = scope + init { radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope) radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index 027947453..7f9e6c3fa 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import okio.ByteString.Companion.toByteString +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch @@ -64,12 +65,8 @@ class MeshActionHandlerImpl( private val notificationManager: NotificationManager, private val messageProcessor: Lazy, private val radioConfigRepository: RadioConfigRepository, + @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshActionHandler { - private lateinit var scope: CoroutineScope - - override fun start(scope: CoroutineScope) { - this.scope = scope - } companion object { private const val DEFAULT_REBOOT_DELAY = 5 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index dc544a300..b7b27aa4e 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -21,6 +21,7 @@ import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import okio.IOException +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.ConnectionState @@ -56,17 +57,13 @@ class MeshConfigFlowManagerImpl( private val analytics: PlatformAnalytics, private val commandSender: CommandSender, private val packetHandler: PacketHandler, + @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConfigFlowManager { - private lateinit var scope: CoroutineScope private val wantConfigDelay = 100L /** Monotonically increasing generation so async clears from a stale handshake are discarded. */ private val handshakeGeneration = atomic(0L) - override fun start(scope: CoroutineScope) { - this.scope = scope - } - /** * Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase, * eliminating the possibility of accessing stale or uninitialized fields. diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt index 06d973204..b622cedbf 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.repository.MeshConfigHandler @@ -40,8 +41,8 @@ class MeshConfigHandlerImpl( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val nodeManager: NodeManager, + @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConfigHandler { - private lateinit var scope: CoroutineScope private val _localConfig = MutableStateFlow(LocalConfig()) override val localConfig = _localConfig.asStateFlow() @@ -49,8 +50,7 @@ class MeshConfigHandlerImpl( private val _moduleConfig = MutableStateFlow(LocalModuleConfig()) override val moduleConfig = _moduleConfig.asStateFlow() - override fun start(scope: CoroutineScope) { - this.scope = scope + init { radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope) radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index 5954b579c..fde8841ce 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -19,13 +19,13 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis @@ -81,17 +81,15 @@ class MeshConnectionManagerImpl( private val packetRepository: PacketRepository, private val workerManager: MeshWorkerManager, private val appWidgetUpdater: AppWidgetUpdater, + @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConnectionManager { - private lateinit var scope: CoroutineScope private var sleepTimeout: Job? = null private var locationRequestsJob: Job? = null private var handshakeTimeout: Job? = null private var connectTimeMsec = 0L private var connectionRestored = false - @OptIn(FlowPreview::class) - override fun start(scope: CoroutineScope) { - this.scope = scope + init { radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope) // Ensure notification title and content stay in sync with state changes @@ -302,8 +300,7 @@ class MeshConnectionManagerImpl( // Start MQTT if enabled scope.handledLaunch { val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - mqttManager.start( - scope, + mqttManager.startProxy( moduleConfig.mqtt?.enabled == true, moduleConfig.mqtt?.proxy_to_client_enabled == true, ) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 07521b21c..5da0448b5 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis @@ -94,14 +95,8 @@ class MeshDataHandlerImpl( private val storeForwardHandler: StoreForwardPacketHandler, private val telemetryHandler: TelemetryPacketHandler, private val adminPacketHandler: AdminPacketHandler, + @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshDataHandler { - private lateinit var scope: CoroutineScope - - override fun start(scope: CoroutineScope) { - this.scope = scope - storeForwardHandler.start(scope) - telemetryHandler.start(scope) - } private val rememberDataType = setOf( diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index 9fd28ecb4..288ae9645 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis @@ -53,8 +54,8 @@ class MeshMessageProcessorImpl( private val meshLogRepository: Lazy, private val router: Lazy, private val fromRadioDispatcher: FromRadioPacketHandler, + @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshMessageProcessor { - private lateinit var scope: CoroutineScope private val mapsMutex = Mutex() private val logUuidByPacketId = mutableMapOf() @@ -75,8 +76,7 @@ class MeshMessageProcessorImpl( scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } } } - override fun start(scope: CoroutineScope) { - this.scope = scope + init { nodeManager.isNodeDbReady .onEach { ready -> if (ready) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt index aaf109be9..8973589bd 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.data.manager -import kotlinx.coroutines.CoroutineScope import org.koin.core.annotation.Single import org.meshtastic.core.repository.MeshActionHandler import org.meshtastic.core.repository.MeshConfigFlowManager @@ -64,13 +63,4 @@ class MeshRouterImpl( override val xmodemManager: XModemManager get() = xmodemManagerLazy.value - - override fun start(scope: CoroutineScope) { - dataHandler.start(scope) - configHandler.start(scope) - tracerouteHandler.start(scope) - neighborInfoHandler.start(scope) - configFlowManager.start(scope) - actionHandler.start(scope) - } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt index 969b67a2f..b928e8505 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.repository.MqttManager @@ -36,12 +37,11 @@ class MqttManagerImpl( private val mqttRepository: MQTTRepository, private val packetHandler: PacketHandler, private val serviceRepository: ServiceRepository, + @Named("ServiceScope") private val scope: CoroutineScope, ) : MqttManager { - private lateinit var scope: CoroutineScope private var mqttMessageFlow: Job? = null - override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) { - this.scope = scope + override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) { if (mqttMessageFlow?.isActive == true) return if (enabled && proxyToClientEnabled) { mqttMessageFlow = diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 4019e5a9b..3f483ba25 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic import kotlinx.atomicfu.update import kotlinx.collections.immutable.persistentMapOf -import kotlinx.coroutines.CoroutineScope import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis @@ -37,16 +36,11 @@ class NeighborInfoHandlerImpl( private val serviceRepository: ServiceRepository, private val serviceBroadcasts: ServiceBroadcasts, ) : NeighborInfoHandler { - private lateinit var scope: CoroutineScope private val startTimes = atomic(persistentMapOf()) override var lastNeighborInfo: NeighborInfo? = null - override fun start(scope: CoroutineScope) { - this.scope = scope - } - override fun recordStartTime(requestId: Int) { startTimes.update { it.put(requestId, nowMillis) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 9ce4ba05d..fe6d22f4c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import okio.ByteString +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.DataPacket @@ -59,8 +60,8 @@ class NodeManagerImpl( private val nodeRepository: NodeRepository, private val serviceBroadcasts: ServiceBroadcasts, private val notificationManager: NotificationManager, + @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeManager { - private lateinit var scope: CoroutineScope private val _nodeDBbyNodeNum = atomic(persistentMapOf()) private val _nodeDBbyID = atomic(persistentMapOf()) @@ -88,10 +89,6 @@ class NodeManagerImpl( myNodeNum.value = num } - override fun start(scope: CoroutineScope) { - this.scope = scope - } - companion object { private const val TIME_MS_TO_S = 1000L } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt index 1d4d11adc..7c634ee8b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis @@ -60,6 +61,7 @@ class PacketHandlerImpl( private val radioInterfaceService: RadioInterfaceService, private val meshLogRepository: Lazy, private val serviceRepository: ServiceRepository, + @Named("ServiceScope") private val scope: CoroutineScope, ) : PacketHandler { companion object { @@ -67,7 +69,6 @@ class PacketHandlerImpl( } private var queueJob: Job? = null - private lateinit var scope: CoroutineScope private val queueMutex = Mutex() private val queuedPackets = mutableListOf() @@ -79,11 +80,6 @@ class PacketHandlerImpl( private val responseMutex = Mutex() private val queueResponse = mutableMapOf>() - override fun start(scope: CoroutineScope) { - this.scope = scope - queueStopped = false // Safe: called before any concurrent operations on this scope. - } - override fun sendToRadio(p: ToRadio) { Logger.d { "Sending to radio ${p.toPIIString()}" } val b = p.encode() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 4f71879ce..e8ab4eeb7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import okio.ByteString.Companion.toByteString import okio.IOException +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.DataPacket @@ -45,12 +46,8 @@ class StoreForwardPacketHandlerImpl( private val serviceBroadcasts: ServiceBroadcasts, private val historyManager: HistoryManager, private val dataHandler: Lazy, + @Named("ServiceScope") private val scope: CoroutineScope, ) : StoreForwardPacketHandler { - private lateinit var scope: CoroutineScope - - override fun start(scope: CoroutineScope) { - this.scope = scope - } override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { val payload = packet.decoded?.payload ?: return diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt index 205dd30e2..4887ff19b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket @@ -49,16 +50,12 @@ class TelemetryPacketHandlerImpl( private val nodeManager: NodeManager, private val connectionManager: Lazy, private val notificationManager: NotificationManager, + @Named("ServiceScope") private val scope: CoroutineScope, ) : TelemetryPacketHandler { - private lateinit var scope: CoroutineScope private val batteryMutex = Mutex() private val batteryPercentCooldowns = mutableMapOf() - override fun start(scope: CoroutineScope) { - this.scope = scope - } - @Suppress("LongMethod", "CyclomaticComplexMethod") override fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { val payload = packet.decoded?.payload ?: return diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index 5e8d954f6..a5997208b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -22,6 +22,7 @@ import kotlinx.atomicfu.update import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.handledLaunch @@ -42,15 +43,11 @@ class TracerouteHandlerImpl( private val serviceRepository: ServiceRepository, private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val nodeRepository: NodeRepository, + @Named("ServiceScope") private val scope: CoroutineScope, ) : TracerouteHandler { - private lateinit var scope: CoroutineScope private val startTimes = atomic(persistentMapOf()) - override fun start(scope: CoroutineScope) { - this.scope = scope - } - override fun recordStartTime(requestId: Int) { startTimes.update { it.put(requestId, nowMillis) } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt index 6ac094e48..c53c2577e 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt @@ -25,6 +25,7 @@ import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verify.VerifyMode.Companion.not import dev.mokkery.verifySuspend +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -89,28 +90,28 @@ class MeshActionHandlerImplTest { every { nodeManager.myNodeNum } returns myNodeNumFlow every { nodeManager.getMyId() } returns "!12345678" every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - handler = - MeshActionHandlerImpl( - nodeManager = nodeManager, - commandSender = commandSender, - packetRepository = lazy { packetRepository }, - serviceBroadcasts = serviceBroadcasts, - dataHandler = lazy { dataHandler }, - analytics = analytics, - meshPrefs = meshPrefs, - databaseManager = databaseManager, - notificationManager = notificationManager, - messageProcessor = lazy { messageProcessor }, - radioConfigRepository = radioConfigRepository, - ) } + private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl( + nodeManager = nodeManager, + commandSender = commandSender, + packetRepository = lazy { packetRepository }, + serviceBroadcasts = serviceBroadcasts, + dataHandler = lazy { dataHandler }, + analytics = analytics, + meshPrefs = meshPrefs, + databaseManager = databaseManager, + notificationManager = notificationManager, + messageProcessor = lazy { messageProcessor }, + radioConfigRepository = radioConfigRepository, + scope = scope, + ) + // ---- handleUpdateLastAddress (device-switch path — P0 critical) ---- @Test fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -128,7 +129,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr") @@ -141,7 +142,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -156,7 +157,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow(null) @@ -168,7 +169,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -187,7 +188,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) myNodeNumFlow.value = null val node = createTestNode(REMOTE_NODE_NUM) @@ -201,7 +202,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false) handler.onServiceAction(ServiceAction.Favorite(node)) @@ -213,7 +214,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true) handler.onServiceAction(ServiceAction.Favorite(node)) @@ -227,7 +228,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false) handler.onServiceAction(ServiceAction.Ignore(node)) @@ -242,7 +243,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isMuted = false) handler.onServiceAction(ServiceAction.Mute(node)) @@ -256,7 +257,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM)) advanceUntilIdle() @@ -268,7 +269,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true val action = ServiceAction.SendContact(SharedContact()) @@ -281,7 +282,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false val action = ServiceAction.SendContact(SharedContact()) @@ -296,7 +297,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val contact = SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser")) @@ -311,7 +312,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetOwner_sendsAdminAndUpdatesLocalNode() { - handler.start(testScope) + handler = createHandler(testScope) val meshUser = MeshUser( id = "!12345678", @@ -331,7 +332,7 @@ class MeshActionHandlerImplTest { @Test fun handleSend_sendsDataAndBroadcastsStatus() { - handler.start(testScope) + handler = createHandler(testScope) val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0) handler.handleSend(packet, MY_NODE_NUM) @@ -345,7 +346,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_sameNode_doesNothing() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM) @@ -354,7 +355,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() { - handler.start(testScope) + handler = createHandler(testScope) every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) val validPosition = Position(37.7749, -122.4194, 10) @@ -365,7 +366,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() { - handler.start(testScope) + handler = createHandler(testScope) every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) every { nodeManager.nodeDBbyNodeNum } returns emptyMap() @@ -378,7 +379,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_doNotProvide_sendsZeroPosition() { - handler.start(testScope) + handler = createHandler(testScope) every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) val validPosition = Position(37.7749, -122.4194, 10) @@ -392,7 +393,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit val config = Config(lora = Config.LoRaConfig(hop_limit = 5)) @@ -409,7 +410,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) myNodeNumFlow.value = MY_NODE_NUM everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit @@ -425,7 +426,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) myNodeNumFlow.value = MY_NODE_NUM val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) @@ -442,7 +443,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit val channel = Channel(index = 1) @@ -457,7 +458,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetChannel_nullPayload_doesNothing() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleSetChannel(null, MY_NODE_NUM) @@ -468,7 +469,7 @@ class MeshActionHandlerImplTest { @Test fun handleRemoveByNodenum_removesAndSendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM) @@ -480,7 +481,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteOwner_decodesAndSendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) val user = User(id = "!remote01", long_name = "Remote", short_name = "RM") val payload = User.ADAPTER.encode(user) @@ -495,7 +496,7 @@ class MeshActionHandlerImplTest { @Test fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) @@ -504,7 +505,7 @@ class MeshActionHandlerImplTest { @Test fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value) @@ -515,7 +516,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteChannel_nullPayload_doesNothing() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null) @@ -524,7 +525,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) val channel = Channel(index = 2) val payload = Channel.ADAPTER.encode(channel) @@ -538,7 +539,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestRebootOta_withNullHash_sendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null) @@ -547,7 +548,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestRebootOta_withHash_sendsAdmin() { - handler.start(testScope) + handler = createHandler(testScope) val hash = byteArrayOf(0x01, 0x02, 0x03) handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash) @@ -559,7 +560,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() { - handler.start(testScope) + handler = createHandler(testScope) handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt index 9580d5363..e05c6f20a 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -98,8 +98,8 @@ class MeshConfigFlowManagerImplTest { analytics = analytics, commandSender = commandSender, packetHandler = packetHandler, + scope = testScope, ) - manager.start(testScope) } // ---------- handleMyInfo ---------- diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt index b71942d0e..bf3247815 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt @@ -23,6 +23,7 @@ import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verifySuspend +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -60,20 +61,20 @@ class MeshConfigHandlerImplTest { fun setUp() { every { radioConfigRepository.localConfigFlow } returns localConfigFlow every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow - - handler = - MeshConfigHandlerImpl( - radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, - nodeManager = nodeManager, - ) } + private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl( + radioConfigRepository = radioConfigRepository, + serviceRepository = serviceRepository, + nodeManager = nodeManager, + scope = scope, + ) + // ---------- start and flow wiring ---------- @Test fun `start wires localConfig flow from repository`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) localConfigFlow.value = config advanceUntilIdle() @@ -83,7 +84,7 @@ class MeshConfigHandlerImplTest { @Test fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) moduleConfigFlow.value = config advanceUntilIdle() @@ -95,7 +96,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) handler.handleDeviceConfig(config) advanceUntilIdle() @@ -106,7 +107,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val configs = listOf( Config(position = Config.PositionConfig()), @@ -131,7 +132,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) handler.handleModuleConfig(config) advanceUntilIdle() @@ -142,7 +143,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val myNum = 123 every { nodeManager.myNodeNum } returns MutableStateFlow(myNum) @@ -155,7 +156,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { nodeManager.myNodeNum } returns MutableStateFlow(null) val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) @@ -168,7 +169,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel persists channel settings`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val channel = Channel(index = 0) handler.handleChannel(channel) advanceUntilIdle() @@ -178,7 +179,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { nodeManager.getMyNodeInfo() } returns MyNodeInfo( myNodeNum = 123, @@ -206,7 +207,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) every { nodeManager.getMyNodeInfo() } returns null val channel = Channel(index = 0) @@ -220,7 +221,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) { - handler.start(backgroundScope) + handler = createHandler(backgroundScope) val config = DeviceUIConfig() handler.handleDeviceUIConfig(config) advanceUntilIdle() diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index 5263254d3..36ee37f2e 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -24,7 +24,7 @@ import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -60,7 +60,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class MeshConnectionManagerImplTest { private val radioInterfaceService = mock(MockMode.autofill) private val serviceRepository = mock(MockMode.autofill) @@ -108,29 +108,29 @@ class MeshConnectionManagerImplTest { every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() every { packetHandler.sendToRadio(any()) } returns Unit - - manager = - MeshConnectionManagerImpl( - radioInterfaceService, - serviceRepository, - serviceBroadcasts, - serviceNotifications, - uiPrefs, - packetHandler, - nodeRepository, - locationManager, - mqttManager, - historyManager, - radioConfigRepository, - commandSender, - nodeManager, - analytics, - packetRepository, - workerManager, - appWidgetUpdater, - ) } + private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl( + radioInterfaceService, + serviceRepository, + serviceBroadcasts, + serviceNotifications, + uiPrefs, + packetHandler, + nodeRepository, + locationManager, + mqttManager, + historyManager, + radioConfigRepository, + commandSender, + nodeManager, + analytics, + packetRepository, + workerManager, + appWidgetUpdater, + scope, + ) + @AfterTest fun tearDown() {} @Test @@ -138,7 +138,7 @@ class MeshConnectionManagerImplTest { every { packetHandler.sendToRadio(any()) } returns Unit every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - manager.start(backgroundScope) + manager = createManager(backgroundScope) radioConnectionState.value = ConnectionState.Connected advanceUntilIdle() @@ -163,7 +163,7 @@ class MeshConnectionManagerImplTest { every { locationManager.stop() } returns Unit every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - manager.start(backgroundScope) + manager = createManager(backgroundScope) // Transition to Connected first so that Disconnected actually does something radioConnectionState.value = ConnectionState.Connected advanceUntilIdle() @@ -197,7 +197,7 @@ class MeshConnectionManagerImplTest { every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - manager.start(backgroundScope) + manager = createManager(backgroundScope) advanceUntilIdle() radioConnectionState.value = ConnectionState.DeviceSleep @@ -221,7 +221,7 @@ class MeshConnectionManagerImplTest { every { locationManager.stop() } returns Unit every { mqttManager.stop() } returns Unit - manager.start(backgroundScope) + manager = createManager(backgroundScope) advanceUntilIdle() radioConnectionState.value = ConnectionState.DeviceSleep @@ -236,7 +236,7 @@ class MeshConnectionManagerImplTest { @Test fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) { - manager.start(backgroundScope) + manager = createManager(backgroundScope) val packetId = 456 everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket) every { workerManager.enqueueSendMessage(any()) } returns Unit @@ -257,15 +257,15 @@ class MeshConnectionManagerImplTest { moduleConfigFlow.value = moduleConfig every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit every { nodeManager.myNodeNum } returns MutableStateFlow(123) - every { mqttManager.start(any(), any(), any()) } returns Unit + every { mqttManager.startProxy(any(), any()) } returns Unit every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit every { nodeManager.getMyNodeInfo() } returns null - manager.start(backgroundScope) + manager = createManager(backgroundScope) manager.onNodeDbReady() advanceUntilIdle() - verify { mqttManager.start(any(), true, true) } + verify { mqttManager.startProxy(true, true) } verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) } } @@ -286,7 +286,7 @@ class MeshConnectionManagerImplTest { every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - manager.start(backgroundScope) + manager = createManager(backgroundScope) advanceUntilIdle() // Transition to Connected then DeviceSleep diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 5f738b439..022608be1 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -108,8 +108,8 @@ class MeshDataHandlerTest { storeForwardHandler = storeForwardHandler, telemetryHandler = telemetryHandler, adminPacketHandler = adminPacketHandler, + scope = testScope, ) - handler.start(testScope) // Default: mapper returns null for empty packets, which is the safe default every { dataMapper.toDataPacket(any()) } returns null diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt index 3090cf49e..251aefabe 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt @@ -23,6 +23,7 @@ import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verifySuspend +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -65,22 +66,22 @@ class MeshMessageProcessorImplTest { every { nodeManager.isNodeDbReady } returns isNodeDbReady every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) every { router.dataHandler } returns dataHandler - - processor = - MeshMessageProcessorImpl( - nodeManager = nodeManager, - serviceRepository = serviceRepository, - meshLogRepository = lazy { meshLogRepository }, - router = lazy { router }, - fromRadioDispatcher = fromRadioDispatcher, - ) } + private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl( + nodeManager = nodeManager, + serviceRepository = serviceRepository, + meshLogRepository = lazy { meshLogRepository }, + router = lazy { router }, + fromRadioDispatcher = fromRadioDispatcher, + scope = scope, + ) + // ---------- handleFromRadio: non-packet variants ---------- @Test fun `handleFromRadio dispatches non-packet variants to fromRadioDispatcher`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) val logRecord = LogRecord(message = "test log") val fromRadio = FromRadio(log_record = logRecord) val bytes = FromRadio.ADAPTER.encode(fromRadio) @@ -93,7 +94,7 @@ class MeshMessageProcessorImplTest { @Test fun `handleFromRadio falls back to LogRecord parsing when FromRadio fails`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) // Encode a raw LogRecord (not wrapped in FromRadio) — first decode as FromRadio fails, // fallback decode as LogRecord succeeds val logRecord = LogRecord(message = "fallback log") @@ -108,7 +109,7 @@ class MeshMessageProcessorImplTest { @Test fun `handleFromRadio with completely invalid bytes does not crash`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) // Invalid protobuf bytes — both parses should fail val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte()) @@ -121,7 +122,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets are buffered when node DB is not ready`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = false val packet = @@ -141,7 +142,7 @@ class MeshMessageProcessorImplTest { @Test fun `buffered packets are flushed when node DB becomes ready`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = false val packet = @@ -165,7 +166,7 @@ class MeshMessageProcessorImplTest { @Test fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = false // The maxEarlyPacketBuffer is 10240 — we won't actually fill it in this test, @@ -195,7 +196,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets with rx_time 0 get current time`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = @@ -214,7 +215,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets with non-zero rx_time keep their time`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = @@ -235,7 +236,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket updates myNode lastHeard`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = @@ -255,7 +256,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket updates sender node`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val senderNode = 999 @@ -279,7 +280,7 @@ class MeshMessageProcessorImplTest { @Test fun `packet with null decoded is skipped`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = MeshPacket(id = 1, from = 999, decoded = null) @@ -293,7 +294,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = true val packet = @@ -315,7 +316,7 @@ class MeshMessageProcessorImplTest { @Test fun `clearEarlyPackets empties the buffer`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) isNodeDbReady.value = false val packet = @@ -342,7 +343,7 @@ class MeshMessageProcessorImplTest { @Test fun `FromRadio log_record variant is logged as MeshLog`() = runTest(testDispatcher) { - processor.start(backgroundScope) + processor = createProcessor(backgroundScope) val logRecord = LogRecord(message = "device log") val fromRadio = FromRadio(log_record = logRecord) val bytes = FromRadio.ADAPTER.encode(fromRadio) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 022590467..509066867 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode import dev.mokkery.mock +import kotlinx.coroutines.test.TestScope import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket @@ -44,12 +45,13 @@ class NodeManagerImplTest { private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill) private val notificationManager: NotificationManager = mock(MockMode.autofill) + private val testScope = TestScope() private lateinit var nodeManager: NodeManagerImpl @BeforeTest fun setUp() { - nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager) + nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope) } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt index fe89063ef..0a1698c9a 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt @@ -70,8 +70,8 @@ class PacketHandlerImplTest { radioInterfaceService, lazy { meshLogRepository }, serviceRepository, + testScope, ) - handler.start(testScope) } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index e465aaa63..900245332 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -72,8 +72,8 @@ class StoreForwardPacketHandlerImplTest { serviceBroadcasts = serviceBroadcasts, historyManager = historyManager, dataHandler = lazy { dataHandler }, + scope = testScope, ) - handler.start(testScope) } private fun makeSfPacket(from: Int, sf: StoreAndForward): MeshPacket { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt index 8f295a2b6..28bf22fdc 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt @@ -62,8 +62,8 @@ class TelemetryPacketHandlerImplTest { nodeManager = nodeManager, connectionManager = lazy { connectionManager }, notificationManager = notificationManager, + scope = testScope, ) - handler.start(testScope) } private fun makeTelemetryPacket(from: Int, telemetry: Telemetry): MeshPacket { diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index 2b897baa9..b99a002de 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import okio.ByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Position @@ -27,9 +26,6 @@ import org.meshtastic.proto.LocalConfig /** Interface for sending commands and packets to the mesh network. */ @Suppress("TooManyFunctions") interface CommandSender { - /** Starts the command sender with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Returns the current packet ID. */ fun getCurrentPacketId(): Long diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt index ac92e8287..5c43efdcd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MeshUser import org.meshtastic.core.model.Position @@ -25,9 +24,6 @@ import org.meshtastic.core.model.service.ServiceAction /** Interface for handling UI-triggered actions and administrative commands for the mesh. */ @Suppress("TooManyFunctions") interface MeshActionHandler { - /** Starts the handler with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Processes a service action from the UI. */ suspend fun onServiceAction(action: ServiceAction) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt index 2a92f8909..b2bb6d418 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FileInfo import org.meshtastic.proto.MyNodeInfo @@ -24,9 +23,6 @@ import org.meshtastic.proto.NodeInfo /** Interface for managing the configuration flow, including local node info and metadata. */ interface MeshConfigFlowManager { - /** Starts the manager with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Handles received local node information. */ fun handleMyInfo(myInfo: MyNodeInfo) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt index 3f3887631..c0e60337e 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -27,9 +26,6 @@ import org.meshtastic.proto.ModuleConfig /** Interface for handling device and module configuration updates. */ interface MeshConfigHandler { - /** Starts the handler with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Reactive local configuration. */ val localConfig: StateFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt index eae5bd9a0..c60db9afa 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt @@ -16,14 +16,10 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.Telemetry /** Interface for managing the connection lifecycle and status with the mesh radio. */ interface MeshConnectionManager { - /** Starts the connection manager with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Called when the radio configuration has been fully loaded. */ fun onRadioConfigLoaded() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt index 2c7487cf9..7d5f2a913 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt @@ -16,16 +16,12 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import org.meshtastic.core.model.DataPacket import org.meshtastic.proto.MeshPacket /** Interface for handling incoming mesh data packets and routing them to the appropriate handlers. */ interface MeshDataHandler { - /** Starts the handler with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** * Processes a received mesh packet. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt index 1a3657d9e..a8d6545ce 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt @@ -16,14 +16,10 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MeshPacket /** Interface for processing incoming radio messages and mesh packets. */ interface MeshMessageProcessor { - /** Starts the processor with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Handles a raw message received from the radio. */ fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt index be2830af9..42b306b17 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt @@ -16,13 +16,8 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope - /** Interface for the central router that orchestrates specialized mesh packet handlers. */ interface MeshRouter { - /** Starts the router and its sub-components with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Access to the data handler. */ val dataHandler: MeshDataHandler diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt index cfda5a9d0..7ebfa0521 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt @@ -16,13 +16,12 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MqttClientProxyMessage /** Interface for managing MQTT proxy communication. */ interface MqttManager { - /** Starts the MQTT manager with the given coroutine scope and settings. */ - fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) + /** Starts the MQTT proxy with the given settings. */ + fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) /** Stops the MQTT manager. */ fun stop() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt index b9759ff59..903146331 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt @@ -16,15 +16,11 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NeighborInfo /** Interface for handling neighbor info responses from the mesh. */ interface NeighborInfoHandler { - /** Starts the neighbor info handler with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Records the start time for a neighbor info request. */ fun recordStartTime(requestId: Int) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index a0d115391..ac6718572 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node @@ -51,9 +50,6 @@ interface NodeManager : NodeIdLookup { /** Sets whether node database writes are allowed. */ fun setAllowNodeDbWrites(allowed: Boolean) - /** Starts the node manager with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** The local node number as a thread-safe [StateFlow]. */ val myNodeNum: StateFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt index 686840f40..081e2928b 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt @@ -16,16 +16,12 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.QueueStatus import org.meshtastic.proto.ToRadio /** Interface for handling the transmission of packets to the radio and managing the packet queue. */ interface PacketHandler { - /** Starts the packet handler with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Sends a command/packet directly to the radio. */ fun sendToRadio(p: ToRadio) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt index 51006763d..bda122ac1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt @@ -16,15 +16,11 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.model.DataPacket import org.meshtastic.proto.MeshPacket /** Interface for handling Store & Forward (legacy) and SF++ packets. */ interface StoreForwardPacketHandler { - /** Starts the handler with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** * Handles a legacy Store & Forward packet. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt index a53cd8b8a..b1f1aa2c9 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt @@ -16,15 +16,11 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.model.DataPacket import org.meshtastic.proto.MeshPacket /** Interface for handling telemetry packets from the mesh, including battery notifications. */ interface TelemetryPacketHandler { - /** Starts the handler with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** * Processes a telemetry packet. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt index aa2e6318a..6535ef30c 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt @@ -16,15 +16,11 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import org.meshtastic.proto.MeshPacket /** Interface for handling traceroute responses from the mesh. */ interface TracerouteHandler { - /** Starts the traceroute handler with the given coroutine scope. */ - fun start(scope: CoroutineScope) - /** Records the start time for a traceroute request. */ fun recordStartTime(requestId: Int) diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 7e9832b54..e651d95ce 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -22,16 +22,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs @@ -51,25 +49,22 @@ import org.meshtastic.core.takserver.TAKServerManager class MeshServiceOrchestrator( private val radioInterfaceService: RadioInterfaceService, private val serviceRepository: ServiceRepository, - private val packetHandler: PacketHandler, private val nodeManager: NodeManager, private val messageProcessor: MeshMessageProcessor, - private val commandSender: CommandSender, - private val connectionManager: MeshConnectionManager, private val router: MeshRouter, private val serviceNotifications: MeshServiceNotifications, private val takServerManager: TAKServerManager, private val takMeshIntegration: TAKMeshIntegration, private val takPrefs: TakPrefs, - private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers, private val databaseManager: DatabaseManager, + @Named("ServiceScope") private val scope: CoroutineScope, ) { private var serviceJob: Job? = null private var takJob: Job? = null - /** The coroutine scope for the service. Available after [start] is called. */ - var serviceScope: CoroutineScope? = null - private set + /** The coroutine scope for the service. */ + val serviceScope: CoroutineScope + get() = scope /** Whether the orchestrator is currently running. */ val isRunning: Boolean @@ -78,8 +73,8 @@ class MeshServiceOrchestrator( /** * Starts the mesh service components and wires up data flows. * - * This is the KMP equivalent of `MeshService.onCreate()`. It starts all managers, connects to the radio, and wires - * incoming radio data to the message processor and service actions to the router's action handler. + * This is the KMP equivalent of `MeshService.onCreate()`. It connects to the radio and wires incoming radio data to + * the message processor and service actions to the router's action handler. */ fun start() { if (isRunning) { @@ -90,18 +85,9 @@ class MeshServiceOrchestrator( Logger.i { "Starting mesh service orchestrator" } val job = Job() serviceJob = job - val scope = CoroutineScope(dispatchers.default + job) - serviceScope = scope serviceNotifications.initChannels() - packetHandler.start(scope) - router.start(scope) - nodeManager.start(scope) - connectionManager.start(scope) - messageProcessor.start(scope) - commandSender.start(scope) - // Observe TAK server pref to start/stop takJob = takPrefs.isTakServerEnabled @@ -161,6 +147,5 @@ class MeshServiceOrchestrator( } serviceJob?.cancel() serviceJob = null - serviceScope = null } } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/di/CoreServiceModule.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/di/CoreServiceModule.kt index d007f1ea3..3fae4287b 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/di/CoreServiceModule.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/di/CoreServiceModule.kt @@ -16,9 +16,19 @@ */ package org.meshtastic.core.service.di +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers @Module @ComponentScan("org.meshtastic.core.service") -class CoreServiceModule +class CoreServiceModule { + @Single + @Named("ServiceScope") + fun provideServiceScope(dispatchers: CoroutineDispatchers): CoroutineScope = + CoroutineScope(dispatchers.default + SupervisorJob()) +} diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 611454d05..48be7dbf6 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -25,23 +25,21 @@ import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshActionHandler import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs @@ -57,12 +55,10 @@ class MeshServiceOrchestratorTest { private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) - private val packetHandler: PacketHandler = mock(MockMode.autofill) private val nodeManager: NodeManager = mock(MockMode.autofill) private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill) private val commandSender: CommandSender = mock(MockMode.autofill) - private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) private val router: MeshRouter = mock(MockMode.autofill) private val actionHandler: MeshActionHandler = mock(MockMode.autofill) private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) @@ -73,7 +69,7 @@ class MeshServiceOrchestratorTest { private val databaseManager: DatabaseManager = mock(MockMode.autofill) private val testDispatcher = UnconfinedTestDispatcher() - private val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) + private val testScope = CoroutineScope(testDispatcher) /** Stubs the shared flow dependencies used by every test and returns an orchestrator. */ private fun createOrchestrator( @@ -107,18 +103,15 @@ class MeshServiceOrchestratorTest { return MeshServiceOrchestrator( radioInterfaceService = radioInterfaceService, serviceRepository = serviceRepository, - packetHandler = packetHandler, nodeManager = nodeManager, messageProcessor = messageProcessor, - commandSender = commandSender, - connectionManager = connectionManager, router = router, serviceNotifications = serviceNotifications, takServerManager = takServerManager, takMeshIntegration = takMeshIntegration, takPrefs = takPrefs, - dispatchers = dispatchers, databaseManager = databaseManager, + scope = testScope, ) } @@ -131,7 +124,6 @@ class MeshServiceOrchestratorTest { assertTrue(orchestrator.isRunning) verify { serviceNotifications.initChannels() } - verify { packetHandler.start(any()) } verify { nodeManager.loadCachedNodeDB() } orchestrator.stop() @@ -217,7 +209,6 @@ class MeshServiceOrchestratorTest { // Components should only be initialized once verify(exactly(1)) { serviceNotifications.initChannels() } - verify(exactly(1)) { packetHandler.start(any()) } verify(exactly(1)) { nodeManager.loadCachedNodeDB() } orchestrator.stop() From 62264b10c6c64ac8df2397af6a8b917f1ef6aa01 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:41:34 -0500 Subject: [PATCH 014/114] refactor(model): remove ConnectionState helper methods and fix updateStatusNotification return type (#5074) --- .../kotlin/org/meshtastic/app/service/Fakes.kt | 3 +-- .../data/manager/MeshConnectionManagerImpl.kt | 3 ++- .../org/meshtastic/core/model/ConnectionState.kt | 8 -------- .../core/repository/MeshConnectionManager.kt | 4 ++-- .../core/repository/MeshServiceNotifications.kt | 2 +- .../org/meshtastic/core/service/MeshService.kt | 10 +++++++++- .../core/service/MeshServiceNotificationsImpl.kt | 16 ++++++++++++++-- .../core/testing/FakeMeshServiceNotifications.kt | 2 +- .../DesktopMeshServiceNotifications.kt | 3 +-- .../org/meshtastic/desktop/stub/NoopStubs.kt | 2 +- .../feature/connections/ui/ConnectionsScreen.kt | 6 +++--- .../ui/components/ConnectingDeviceInfo.kt | 2 +- .../connections/ui/components/DeviceListItem.kt | 10 +++++----- .../org/meshtastic/feature/messaging/Message.kt | 5 +++-- .../feature/messaging/ui/contact/Contacts.kt | 5 +++-- .../feature/settings/SettingsViewModel.kt | 5 ++++- 16 files changed, 51 insertions(+), 35 deletions(-) diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt index 8f262c47c..37c19f477 100644 --- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt +++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.app.service -import android.app.Notification import dev.mokkery.MockMode import dev.mokkery.mock import org.meshtastic.core.model.Node @@ -37,7 +36,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun updateServiceStateNotification( state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?, - ): Notification = mock(MockMode.autofill) + ) {} override suspend fun updateMessageNotification( contactKey: String, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index fde8841ce..dbf07fdaf 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -347,11 +347,12 @@ class MeshConnectionManagerImpl( updateStatusNotification(t) } - override fun updateStatusNotification(telemetry: Telemetry?): Any = + override fun updateStatusNotification(telemetry: Telemetry?) { serviceNotifications.updateServiceStateNotification( serviceRepository.connectionState.value, telemetry = telemetry, ) + } companion object { private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30 diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt index 0af5a0efd..505f187ea 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt @@ -28,12 +28,4 @@ sealed class ConnectionState { /** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */ data object DeviceSleep : ConnectionState() - - fun isConnected() = this == Connected - - fun isConnecting() = this == Connecting - - fun isDisconnected() = this == Disconnected - - fun isDeviceSleep() = this == DeviceSleep } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt index c60db9afa..9f9851072 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt @@ -35,6 +35,6 @@ interface MeshConnectionManager { /** Updates the telemetry information for the local node. */ fun updateTelemetry(t: Telemetry) - /** Updates and returns the current status notification. */ - fun updateStatusNotification(telemetry: Telemetry? = null): Any + /** Updates the current status notification. */ + fun updateStatusNotification(telemetry: Telemetry? = null) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt index 195a241ee..30aade866 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt @@ -28,7 +28,7 @@ interface MeshServiceNotifications { fun initChannels() - fun updateServiceStateNotification(state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?): Any + fun updateServiceStateNotification(state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?) suspend fun updateMessageNotification( contactKey: String, diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 701ca2d69..05f1135f1 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -42,6 +42,7 @@ import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshRouter +import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.SERVICE_NOTIFY_ID @@ -67,6 +68,12 @@ class MeshService : Service() { private val connectionManager: MeshConnectionManager by inject() + private val notifications: MeshServiceNotifications by inject() + + /** Android-typed accessor for the foreground service notification. */ + private val androidNotifications: MeshServiceNotificationsImpl + get() = notifications as MeshServiceNotificationsImpl + private val orchestrator: MeshServiceOrchestrator by inject() private val router: MeshRouter by inject() @@ -130,7 +137,8 @@ class MeshService : Service() { val a = radioInterfaceService.getDeviceAddress() val wantForeground = a != null && a != "n" - val notification = connectionManager.updateStatusNotification() as android.app.Notification + connectionManager.updateStatusNotification() + val notification = androidNotifications.getServiceNotification() val foregroundServiceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index 05fe1d3b4..75bbe27ce 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -288,13 +288,25 @@ class MeshServiceNotificationsImpl( private var cachedLocalStats: LocalStats? = null private var nextStatsUpdateMillis: Long = 0 private var cachedMessage: String? = null + private var cachedServiceNotification: Notification? = null + + /** + * Returns the last-built service state notification, or builds a default one if none exists. This is used by + * [MeshService] for [android.app.Service.startForeground]. + */ + fun getServiceNotification(): Notification = cachedServiceNotification + ?: createServiceStateNotification( + name = getString(Res.string.meshtastic_app_name), + message = null, + nextUpdateAt = 0, + ) // region Public Notification Methods @Suppress("CyclomaticComplexMethod", "NestedBlockDepth") override fun updateServiceStateNotification( state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?, - ): Notification { + ) { val summaryString = when (state) { is org.meshtastic.core.model.ConnectionState.Connected -> @@ -357,8 +369,8 @@ class MeshServiceNotificationsImpl( message = cachedMessage, nextUpdateAt = nextStatsUpdateMillis, ) + cachedServiceNotification = notification notificationManager.notify(SERVICE_NOTIFY_ID, notification) - return notification } override suspend fun updateMessageNotification( diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt index c90e69da9..dc36b9956 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt @@ -31,7 +31,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun updateServiceStateNotification( state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?, - ): Any = Any() + ) {} override suspend fun updateMessageNotification( contactKey: String, diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index 061da246d..a5ec5b795 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -45,9 +45,8 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat override fun updateServiceStateNotification( state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?, - ): Any { + ) { // We don't have a foreground service on desktop - return Unit } override suspend fun updateMessageNotification( diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index 563571ef6..8d53990e2 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -114,7 +114,7 @@ class NoopMeshServiceNotifications : MeshServiceNotifications { override fun updateServiceStateNotification( state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?, - ): Any = Unit + ) {} override suspend fun updateMessageNotification( contactKey: String, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 828b7be2f..441b81c84 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -151,7 +151,7 @@ fun ConnectionsScreen( MainAppBar( title = stringResource(Res.string.connections), ourNode = ourNode, - showNodeChip = ourNode != null && connectionState.isConnected(), + showNodeChip = ourNode != null && connectionState is ConnectionState.Connected, canNavigateUp = false, onNavigateUp = {}, actions = {}, @@ -167,8 +167,8 @@ fun ConnectionsScreen( Spacer(modifier = Modifier.height(4.dp)) val uiState = when { - connectionState.isConnected() && ourNode != null -> 2 - connectionState.isConnected() || + connectionState is ConnectionState.Connected && ourNode != null -> 2 + connectionState is ConnectionState.Connected || connectionState == ConnectionState.Connecting || selectedDevice != NO_DEVICE_SELECTED -> 1 diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt index 9c86a17bf..53cec80b5 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt @@ -51,7 +51,7 @@ fun ConnectingDeviceInfo( modifier: Modifier = Modifier, ) { val statusText = - if (connectionState.isConnected()) { + if (connectionState is ConnectionState.Connected) { stringResource(Res.string.connected) } else { stringResource(Res.string.connecting) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index 7071c18c9..8499d4e20 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -90,9 +90,9 @@ fun DeviceListItem( val icon = when (device) { is DeviceListEntry.Ble -> - if (connectionState.isConnected()) { + if (connectionState is ConnectionState.Connected) { MeshtasticIcons.BluetoothConnected - } else if (connectionState.isConnecting()) { + } else if (connectionState is ConnectionState.Connecting) { MeshtasticIcons.BluetoothSearching } else { MeshtasticIcons.Bluetooth @@ -132,7 +132,7 @@ fun DeviceListItem( contentDescription = contentDescription, modifier = Modifier.size(32.dp), tint = - if (connectionState.isConnected()) { + if (connectionState is ConnectionState.Connected) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -146,10 +146,10 @@ fun DeviceListItem( Rssi(rssi = displayedRssi) } - if (connectionState.isConnecting()) { + if (connectionState is ConnectionState.Connecting) { CircularProgressIndicator(modifier = Modifier.size(32.dp)) } else { - RadioButton(selected = connectionState.isConnected(), onClick = null) + RadioButton(selected = connectionState is ConnectionState.Connected, onClick = null) } } }, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt index 8d9236a8a..8cc621e1c 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -65,6 +65,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.getChannel @@ -327,7 +328,7 @@ fun MessageScreen( Column { AnimatedVisibility(visible = showQuickChat) { QuickChatRow( - enabled = connectionState.isConnected(), + enabled = connectionState is ConnectionState.Connected, actions = quickChatActions, onClick = { action -> handleQuickChatAction( @@ -344,7 +345,7 @@ fun MessageScreen( ourNode = ourNode, ) MessageInput( - isEnabled = connectionState.isConnected(), + isEnabled = connectionState is ConnectionState.Connected, isHomoglyphEncodingEnabled = homoglyphEncodingEnabled, textFieldState = messageInputState, onSendMessage = { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index e522ba0e2..ac6232ac2 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -64,6 +64,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Contact import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.util.TimeConstants @@ -232,7 +233,7 @@ fun ContactsScreen( MainAppBar( title = stringResource(Res.string.conversations), ourNode = ourNode, - showNodeChip = ourNode != null && connectionState.isConnected(), + showNodeChip = ourNode != null && connectionState is ConnectionState.Connected, canNavigateUp = false, onNavigateUp = {}, actions = { @@ -250,7 +251,7 @@ fun ContactsScreen( ) }, floatingActionButton = { - if (connectionState.isConnected()) { + if (connectionState is ConnectionState.Connected) { MeshtasticImportFAB( sharedContact = sharedContactRequested, onImport = { uriString -> diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index a6c8abfb9..27c57fafe 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -41,6 +41,7 @@ import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController @@ -84,7 +85,9 @@ class SettingsViewModel( val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo val isConnected = - radioController.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false) + radioController.connectionState + .map { it is ConnectionState.Connected } + .stateInWhileSubscribed(initialValue = false) val localConfig: StateFlow = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) From 5e44cbd3a902f4152905e1d9558db0e92efe193b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:49:09 -0500 Subject: [PATCH 015/114] fix(data): make MeshConnectionManagerImpl.onConnectionChanged atomic (#5076) --- .../data/manager/MeshConnectionManagerImpl.kt | 14 ++- .../manager/MeshConnectionManagerImplTest.kt | 99 +++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index dbf07fdaf..d64753bbf 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch @@ -83,6 +85,12 @@ class MeshConnectionManagerImpl( private val appWidgetUpdater: AppWidgetUpdater, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConnectionManager { + /** + * Serializes [onConnectionChanged] to prevent TOCTOU races when multiple coroutines emit state transitions + * concurrently (e.g. flow collector vs. sleep-timeout coroutine). + */ + private val connectionMutex = Mutex() + private var sleepTimeout: Job? = null private var locationRequestsJob: Job? = null private var handshakeTimeout: Job? = null @@ -139,14 +147,14 @@ class MeshConnectionManagerImpl( onConnectionChanged(effectiveState) } - private fun onConnectionChanged(c: ConnectionState) { + private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock { val current = serviceRepository.connectionState.value - if (current == c) return + if (current == c) return@withLock // If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting) if (c is ConnectionState.Connected && current is ConnectionState.Connecting) { Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" } - return + return@withLock } Logger.i { "onConnectionChanged: $current -> $c" } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index 36ee37f2e..55adf8b57 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -27,6 +27,7 @@ import dev.mokkery.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -310,4 +311,102 @@ class MeshConnectionManagerImplTest { "Should transition to Disconnected after capped timeout (300s), not the raw 3630s", ) } + + @Test + fun `rapid state transitions are serialized by connectionMutex`() = runTest(testDispatcher) { + // Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected) + val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) + every { radioConfigRepository.localConfigFlow } returns flowOf(config) + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + every { packetHandler.stopPacketQueue() } returns Unit + every { locationManager.stop() } returns Unit + every { mqttManager.stop() } returns Unit + every { nodeManager.nodeDBbyNodeNum } returns emptyMap() + + // Record every state transition so we can verify ordering + val observed = mutableListOf() + every { serviceRepository.setConnectionState(any()) } calls + { call -> + val state = call.arg(0) + observed.add(state) + connectionStateFlow.value = state + } + + manager = createManager(backgroundScope) + advanceUntilIdle() + + // Rapid-fire: Connected -> DeviceSleep -> Disconnected without yielding between them. + // Without the Mutex, the intermediate DeviceSleep could be missed or applied out of order. + radioConnectionState.value = ConnectionState.Connected + radioConnectionState.value = ConnectionState.DeviceSleep + radioConnectionState.value = ConnectionState.Disconnected + advanceUntilIdle() + + // Verify final state + assertEquals( + ConnectionState.Disconnected, + serviceRepository.connectionState.value, + "Final state should be Disconnected after rapid transitions", + ) + + // Verify that all intermediate states were observed in correct order. + // Connected triggers handleConnected() which sets Connecting (handshake start), + // then DeviceSleep, then Disconnected. + assertEquals( + listOf(ConnectionState.Connecting, ConnectionState.DeviceSleep, ConnectionState.Disconnected), + observed, + "State transitions should be serialized in order: Connecting -> DeviceSleep -> Disconnected", + ) + } + + @Test + fun `concurrent sleep-timeout and radio state change are serialized`() { + val standardDispatcher = StandardTestDispatcher() + runTest(standardDispatcher) { + // Power saving enabled with a short ls_secs so the sleep timeout fires quickly + val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1)) + every { radioConfigRepository.localConfigFlow } returns flowOf(config) + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + every { packetHandler.stopPacketQueue() } returns Unit + every { locationManager.stop() } returns Unit + every { mqttManager.stop() } returns Unit + every { nodeManager.nodeDBbyNodeNum } returns emptyMap() + + val observed = mutableListOf() + every { serviceRepository.setConnectionState(any()) } calls + { call -> + val state = call.arg(0) + observed.add(state) + connectionStateFlow.value = state + } + + manager = createManager(backgroundScope) + advanceUntilIdle() + + // Transition to Connected -> DeviceSleep to start the sleep timer + radioConnectionState.value = ConnectionState.Connected + advanceUntilIdle() + radioConnectionState.value = ConnectionState.DeviceSleep + advanceUntilIdle() + + observed.clear() + + // Before the sleep timeout fires, emit Connected from the radio (simulating device + // waking up). Then let the timeout fire. The mutex ensures they don't race. + radioConnectionState.value = ConnectionState.Connected + // Advance past the sleep timeout (ls_secs=1 + 30s base = 31s) + advanceTimeBy(32_000L) + advanceUntilIdle() + + // The Connected transition should have cancelled the sleep timeout, so we should + // end up in Connecting (from handleConnected), NOT Disconnected (from timeout). + assertEquals( + ConnectionState.Connecting, + serviceRepository.connectionState.value, + "Connected should cancel the sleep timeout; final state should be Connecting", + ) + } + } } From 9468bc6ebe7d423387a4f61d81986199443b43b3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:50:52 -0500 Subject: [PATCH 016/114] refactor(service): unify dual connectionState flows into single source of truth (#5077) --- .../data/manager/MeshConnectionManagerImpl.kt | 10 ++++++ .../meshtastic/core/model/RadioController.kt | 11 +++++- .../core/repository/RadioInterfaceService.kt | 34 +++++++++++++++++-- .../core/repository/ServiceRepository.kt | 29 ++++++++++++++-- .../service/AndroidRadioControllerImpl.kt | 1 + .../core/service/DirectRadioControllerImpl.kt | 1 + .../core/service/ServiceRepositoryImpl.kt | 2 +- .../service/SharedRadioInterfaceService.kt | 9 +++++ .../core/testing/FakeRadioController.kt | 1 + .../core/testing/FakeRadioInterfaceService.kt | 10 +++++- .../core/testing/FakeServiceRepository.kt | 1 + .../core/ui/viewmodel/UIViewModel.kt | 2 +- 12 files changed, 103 insertions(+), 8 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index d64753bbf..918f25719 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -98,6 +98,9 @@ class MeshConnectionManagerImpl( private var connectionRestored = false init { + // Bridge transport-level state into the canonical app-level state. + // This is the ONLY consumer of RadioInterfaceService.connectionState — it applies + // light-sleep policy and handshake awareness before writing to ServiceRepository. radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope) // Ensure notification title and content stay in sync with state changes @@ -131,6 +134,13 @@ class MeshConnectionManagerImpl( .launchIn(scope) } + /** + * Bridges a transport-level [ConnectionState] into the canonical app-level state. + * + * Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event + * should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state + * transition. + */ private suspend fun onRadioConnectionState(newState: ConnectionState) { val localConfig = radioConfigRepository.localConfigFlow.first() val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index 54797eb75..84994e628 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -28,7 +28,16 @@ import org.meshtastic.proto.ClientNotification */ @Suppress("TooManyFunctions") interface RadioController { - /** Reactive connection state of the radio. */ + /** + * Canonical app-level connection state, delegated from [ServiceRepository][connectionState]. + * + * This exposes the same single source of truth as `ServiceRepository.connectionState`, surfaced through the + * controller interface for convenience in feature modules and ViewModels that depend on [RadioController] rather + * than [ServiceRepository] directly. + * + * This is **not** the transport-level state — it reflects the fully reconciled app-level state including handshake + * progress and device sleep policy. + */ val connectionState: StateFlow /** diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index 2788a7f07..bb9cea52d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -24,12 +24,42 @@ import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity -/** Interface for the low-level radio interface that handles raw byte communication. */ +/** + * Interface for the low-level radio interface that handles raw byte communication. + * + * This is the **transport layer** — it manages the raw hardware connection (BLE, TCP, Serial, USB) to a Meshtastic + * radio. Its [connectionState] reflects whether the physical link is up or down, **before** any handshake or + * config-loading logic is applied. + * + * **Important:** UI and feature modules should **never** observe [connectionState] directly. Instead, they should use + * [ServiceRepository.connectionState], which is the canonical app-level connection state that accounts for handshake + * progress, light-sleep policy, and other higher-level concerns. The only legitimate consumer of this transport-level + * flow is [MeshConnectionManager], which bridges transport state changes into the app-level + * [ServiceRepository.connectionState]. + * + * @see ServiceRepository.connectionState + */ interface RadioInterfaceService { /** The device types supported by this platform's radio interface. */ val supportedDeviceTypes: List - /** Reactive connection state of the radio. */ + /** + * Transport-level connection state of the radio hardware. + * + * This flow reflects the raw state of the physical link (BLE, TCP, Serial, USB): + * - [ConnectionState.Connected] — the transport link is established + * - [ConnectionState.Disconnected] — the transport link is down (permanent) + * - [ConnectionState.DeviceSleep] — the transport link is down (transient, device sleeping) + * + * **This is NOT the canonical app-level connection state.** The transport may report [ConnectionState.Connected] + * while the app is still performing the mesh handshake (config + node-info exchange), during which the app-level + * state remains [ConnectionState.Connecting]. + * + * Only [MeshConnectionManager] should observe this flow. All other consumers (ViewModels, feature modules, UI) must + * use [ServiceRepository.connectionState]. + * + * @see ServiceRepository.connectionState + */ val connectionState: StateFlow /** Flow of the current device address. */ diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 4a8af1143..57b1d71ec 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt @@ -31,14 +31,39 @@ import org.meshtastic.proto.MeshPacket * * This repository acts as the primary data bridge between the long-running mesh service and the UI/Feature layers. It * maintains reactive flows for connection status, error messages, and incoming mesh traffic. + * + * **Connection state contract:** [connectionState] is the **canonical, app-level** connection state that all UI, + * feature modules, and ViewModels should observe. It incorporates handshake progress, light-sleep policy, and transport + * reconciliation — unlike [RadioInterfaceService.connectionState], which only reflects the raw hardware link status. + * The [MeshConnectionManager] is the sole writer of this state; it bridges [RadioInterfaceService.connectionState] + * changes into app-level transitions via [setConnectionState]. + * + * @see RadioInterfaceService.connectionState */ @Suppress("TooManyFunctions") interface ServiceRepository { - /** Reactive flow of the current connection state. */ + /** + * Canonical app-level connection state. + * + * This is the **single source of truth** for connection status across the entire application. All UI components, + * feature modules, and ViewModels should observe this flow — never [RadioInterfaceService.connectionState]. + * + * State transitions are managed exclusively by [MeshConnectionManager], which reconciles transport-level events + * with handshake progress and device sleep policy: + * - [ConnectionState.Disconnected] — no active connection to a radio + * - [ConnectionState.Connecting] — transport is up, mesh handshake (config + node-info) in progress + * - [ConnectionState.Connected] — handshake complete, radio fully operational + * - [ConnectionState.DeviceSleep] — radio entered light-sleep (transient disconnect) + * + * @see RadioInterfaceService.connectionState + */ val connectionState: StateFlow /** - * Updates the current connection state. + * Updates the canonical app-level connection state. + * + * **This should only be called by [MeshConnectionManager].** Direct mutation from other components would bypass the + * transport-to-app reconciliation logic and create state inconsistencies. * * @param connectionState The new [ConnectionState]. */ diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index c7ef0ed10..a96b3ffc1 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -41,6 +41,7 @@ class AndroidRadioControllerImpl( private val nodeRepository: NodeRepository, ) : RadioController { + /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ override val connectionState: StateFlow get() = serviceRepository.connectionState diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt index fce0438dd..a753d2d08 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -63,6 +63,7 @@ class DirectRadioControllerImpl( private val myNodeNum: Int get() = nodeManager.myNodeNum.value ?: 0 + /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ override val connectionState: StateFlow get() = serviceRepository.connectionState diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt index ad5b92bd5..8671188ef 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt @@ -42,7 +42,7 @@ import org.meshtastic.proto.MeshPacket @Suppress("TooManyFunctions") open class ServiceRepositoryImpl : ServiceRepository { - // Connection state to our radio device + // Canonical app-level connection state — written exclusively by MeshConnectionManager. private val _connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow get() = _connectionState diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 0785624f5..1865dd4c6 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -77,6 +77,15 @@ class SharedRadioInterfaceService( override val supportedDeviceTypes: List get() = transportFactory.supportedDeviceTypes + /** + * Transport-level connection state reflecting the raw hardware link status. + * + * Updated directly by [onConnect] and [onDisconnect] when the physical transport (BLE, TCP, Serial) connects or + * disconnects. This is consumed exclusively by + * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager], which reconciles it into the + * canonical app-level + * [ServiceRepository.connectionState][org.meshtastic.core.repository.ServiceRepository.connectionState]. + */ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow = _connectionState.asStateFlow() diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index bf83be372..fac69e28c 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -30,6 +30,7 @@ class FakeRadioController : BaseFake(), RadioController { + /** Canonical app-level connection state, mirroring [ServiceRepository][connectionState] semantics. */ private val _connectionState = mutableStateFlow(ConnectionState.Connected) override val connectionState: StateFlow = _connectionState diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt index e1a26c6c3..3b8c83fe9 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt @@ -28,12 +28,20 @@ import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.repository.RadioInterfaceService -/** A test double for [RadioInterfaceService] that provides an in-memory implementation. */ +/** + * A test double for [RadioInterfaceService] that provides an in-memory implementation. + * + * The [connectionState] here mirrors the transport-level semantics of the real implementation. In production, only + * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager] observes this flow; tests should verify + * that bridging behavior rather than consuming it directly from UI/feature test code (use + * [FakeServiceRepository.connectionState] instead). + */ @Suppress("TooManyFunctions") class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService { override val supportedDeviceTypes: List = emptyList() + /** Transport-level connection state (raw hardware link status). */ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow = _connectionState diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt index 266a0d958..ae06843b6 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt @@ -31,6 +31,7 @@ import org.meshtastic.proto.MeshPacket @Suppress("TooManyFunctions") class FakeServiceRepository : ServiceRepository { + /** Canonical app-level connection state — the single source of truth for UI/feature tests. */ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow = _connectionState diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 95bf4365c..1e2021304 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -241,7 +241,7 @@ class UIViewModel( _sharedContactRequested.value = null } - // Connection state to our radio device + /** Canonical app-level connection state, sourced from [ServiceRepository.connectionState]. */ val connectionState get() = serviceRepository.connectionState From 19502cd1e003cd71455f74b67e5fc80b40727466 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:48:42 -0500 Subject: [PATCH 017/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5078) --- app/src/main/assets/firmware_releases.json | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json index 4d74c2b5a..c639f39e2 100644 --- a/app/src/main/assets/firmware_releases.json +++ b/app/src/main/assets/firmware_releases.json @@ -187,12 +187,5 @@ } ] }, - "pullRequests": [ - { - "id": "9999", - "title": "Use UDP as roof node <---> indoor nodes backchannel", - "page_url": "https://github.com/meshtastic/firmware/pull/9999", - "zip_url": "https://discord.com/invite/meshtastic" - } - ] + "pullRequests": [] } \ No newline at end of file From 962c619c4c3661d26152243bff66e7d1a8e5816c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:09:23 -0500 Subject: [PATCH 018/114] chore(deps): bump Kotlin 2.3.21-RC, Koin plugin 1.0.0-RC1, drop datetime compat (#5079) --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d21691950..b11700f95 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,12 +17,12 @@ navigationevent = "1.1.0-alpha01" paging = "3.4.2" room = "3.0.0-alpha03" koin = "4.2.1" -koin-plugin = "0.6.2" +koin-plugin = "1.0.0-RC1" # Kotlin -kotlin = "2.3.20" +kotlin = "2.3.21-RC" kotlinx-coroutines-android = "1.10.2" -kotlinx-datetime = "0.7.1-0.6.x-compat" +kotlinx-datetime = "0.7.1" kotlinx-serialization = "1.11.0" ktlint = "1.7.1" ktfmt = "0.61" From e85300531e124df08788c1b765d50af1bf6d516d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:22:18 -0500 Subject: [PATCH 019/114] =?UTF-8?q?refactor(transport):=20complete=20trans?= =?UTF-8?q?port=20architecture=20overhaul=20=E2=80=94=20extract=20callback?= =?UTF-8?q?,=20wire=20BleReconnectPolicy,=20fix=20safety=20issues=20(#5080?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/ble/AndroidBluetoothRepository.kt | 3 +- .../meshtastic/core/ble/KableBleConnection.kt | 2 +- .../core/ble/MeshtasticBleDevice.kt | 3 +- .../ble/KableMeshtasticRadioProfileTest.kt | 5 +- .../core/data/manager/CommandSenderImpl.kt | 39 +-- .../data/manager/MeshActionHandlerImpl.kt | 5 +- .../data/manager/MeshConnectionManagerImpl.kt | 3 +- .../core/data/manager/MeshDataHandlerImpl.kt | 3 +- .../data/manager/MeshMessageProcessorImpl.kt | 2 +- .../data/manager/TracerouteHandlerImpl.kt | 2 +- .../manager/MeshConnectionManagerImplTest.kt | 40 +-- .../data/manager/PacketHandlerImplTest.kt | 5 + .../org/meshtastic/core/model/Capabilities.kt | 2 +- .../meshtastic/core/model/ConnectionState.kt | 10 +- .../radio/AndroidRadioTransportFactory.kt | 54 ++- .../core/network/radio/InterfaceFactory.kt | 66 ---- .../network/radio/SerialInterfaceFactory.kt | 28 -- .../core/network/radio/SerialInterfaceSpec.kt | 44 --- ...alInterface.kt => SerialRadioTransport.kt} | 31 +- .../core/network/radio/TCPInterfaceFactory.kt | 27 -- .../core/network/radio/TCPInterfaceSpec.kt | 27 -- .../radio/BaseRadioTransportFactory.kt | 55 +-- ...RadioInterface.kt => BleRadioTransport.kt} | 314 +++++++----------- .../core/network/radio/BleReconnectPolicy.kt | 170 ++++++++++ .../core/network/radio/InterfaceSpec.kt | 28 -- .../network/radio/MockInterfaceFactory.kt | 26 -- .../core/network/radio/MockInterfaceSpec.kt | 30 -- ...MockInterface.kt => MockRadioTransport.kt} | 43 ++- .../core/network/radio/NopInterfaceFactory.kt | 25 -- .../core/network/radio/NopInterfaceSpec.kt | 26 -- .../{NopInterface.kt => NopRadioTransport.kt} | 9 +- ...{StreamInterface.kt => StreamTransport.kt} | 28 +- .../network/repository/MQTTRepositoryImpl.kt | 23 +- .../core/network/transport/HeartbeatSender.kt | 57 ++++ ...erfaceTest.kt => BleRadioTransportTest.kt} | 77 ++--- .../network/radio/BleReconnectPolicyTest.kt | 277 +++++++++++++++ .../network/radio/ReconnectBackoffTest.kt | 2 +- ...nterfaceTest.kt => StreamTransportTest.kt} | 26 +- .../{TCPInterface.kt => TcpRadioTransport.kt} | 60 ++-- .../core/network/transport/TcpTransport.kt | 13 +- .../core/network/SerialTransport.kt | 43 +-- .../repository/MeshServiceNotifications.kt | 3 +- .../core/repository/RadioInterfaceService.kt | 15 +- .../core/repository/RadioTransport.kt | 8 + .../core/repository/RadioTransportCallback.kt | 41 +++ .../core/repository/RadioTransportFactory.kt | 4 +- .../service/AndroidRadioControllerImpl.kt | 34 +- .../meshtastic/core/service/MeshService.kt | 6 + .../service/MeshServiceNotificationsImpl.kt | 14 +- .../service/SharedRadioInterfaceService.kt | 96 +++--- .../testing/FakeMeshServiceNotifications.kt | 6 +- .../core/testing/FakeRadioController.kt | 21 +- .../core/testing/FakeRadioInterfaceService.kt | 2 +- .../core/ui/viewmodel/UIViewModel.kt | 13 +- .../desktop/di/DesktopKoinModule.kt | 42 +-- .../radio/DesktopRadioTransportFactory.kt | 14 +- .../org/meshtastic/desktop/stub/NoopStubs.kt | 63 +--- docs/decisions/architecture-review-2026-03.md | 2 +- docs/kmp-status.md | 6 +- docs/roadmap.md | 2 +- .../feature/connections/ScannerViewModel.kt | 43 +-- .../connections/ui/ConnectionsScreen.kt | 28 +- .../ui/components/ConnectingDeviceInfo.kt | 4 + .../connections/ScannerViewModelTest.kt | 2 +- 64 files changed, 1184 insertions(+), 1018 deletions(-) delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt rename core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/{SerialInterface.kt => SerialRadioTransport.kt} (83%) delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt rename core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/{BleRadioInterface.kt => BleRadioTransport.kt} (52%) create mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceSpec.kt rename core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/{MockInterface.kt => MockRadioTransport.kt} (90%) delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceSpec.kt rename core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/{NopInterface.kt => NopRadioTransport.kt} (69%) rename core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/{StreamInterface.kt => StreamTransport.kt} (66%) create mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt rename core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/{BleRadioInterfaceTest.kt => BleRadioTransportTest.kt} (70%) create mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt rename core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/{StreamInterfaceTest.kt => StreamTransportTest.kt} (75%) rename core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/{TCPInterface.kt => TcpRadioTransport.kt} (57%) create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt index 5b17e264b..b330453e1 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers @@ -86,7 +87,7 @@ class AndroidBluetoothRepository( return } - kotlinx.coroutines.suspendCancellableCoroutine { cont -> + suspendCancellableCoroutine { cont -> val receiver = object : android.content.BroadcastReceiver() { @SuppressLint("MissingPermission") diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index dde1955a5..f658d234c 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -87,7 +87,7 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui * * Connection attempts follow Kable's recommended pattern from the SensorTag sample: try a direct connect first, then * fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller - * ([BleRadioInterface]) owns the macro-level retry/backoff loop. + * ([BleRadioTransport]) owns the macro-level retry/backoff loop. */ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt index eb2ee2129..3342cf24f 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.ble import com.juul.kable.Advertisement +import com.juul.kable.ExperimentalApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -47,7 +48,7 @@ class MeshtasticBleDevice( override val isConnected: Boolean get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address - @OptIn(com.juul.kable.ExperimentalApi::class) + @OptIn(ExperimentalApi::class) override suspend fun readRssi(): Int { val active = ActiveBleConnection.active return if (active != null && active.address == address) { diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt index 8068c9387..64286fd70 100644 --- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt +++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.ble import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -118,8 +119,8 @@ class KableMeshtasticRadioProfileTest { fun `MeshtasticRadioProfile default awaitSubscriptionReady returns immediately`() = runTest { val profile = object : MeshtasticRadioProfile { - override val fromRadio = kotlinx.coroutines.flow.emptyFlow() - override val logRadio = kotlinx.coroutines.flow.emptyFlow() + override val fromRadio = emptyFlow() + override val logRadio = emptyFlow() override suspend fun sendToRadio(packet: ByteArray) {} } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index ca22f927d..fd72ef9c7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -39,18 +39,26 @@ import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.AirQualityMetrics import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Constants import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HostMetrics import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalStats import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.Neighbor import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.Paxcount import org.meshtastic.proto.PortNum +import org.meshtastic.proto.PowerMetrics import org.meshtastic.proto.Telemetry import kotlin.math.absoluteValue import kotlin.random.Random import kotlin.time.Duration.Companion.hours +import org.meshtastic.proto.Position as ProtoPosition @Suppress("TooManyFunctions", "CyclomaticComplexMethod") @Single @@ -68,10 +76,6 @@ class CommandSenderImpl( private val localConfig = MutableStateFlow(LocalConfig()) private val channelSet = MutableStateFlow(ChannelSet()) - // We'll need a way to track connection state in shared code, - // maybe via ServiceRepository or similar. - // For now I'll assume it's injected or available. - init { radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope) radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope) @@ -141,14 +145,11 @@ class CommandSenderImpl( if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) { val actualSize = Data.ADAPTER.encodedSize(data) p.status = MessageStatus.ERROR - // throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})") - // RemoteException is Android specific. For KMP we might want a custom exception. error("Message too long: $actualSize bytes") } else { p.status = MessageStatus.QUEUED } - // TODO: Check connection state sendNow(p) } @@ -191,7 +192,7 @@ class CommandSenderImpl( return packetHandler.sendToRadioAndAwait(packet) } - override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) { + override fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) { val myNum = nodeManager.myNodeNum.value ?: return val idNum = destNum ?: myNum Logger.d { "Sending our position/time to=$idNum $pos" } @@ -217,7 +218,7 @@ class CommandSenderImpl( override fun requestPosition(destNum: Int, currentPosition: Position) { val meshPosition = - org.meshtastic.proto.Position( + ProtoPosition( latitude_i = Position.degI(currentPosition.latitude), longitude_i = Position.degI(currentPosition.longitude), altitude = currentPosition.altitude, @@ -240,7 +241,7 @@ class CommandSenderImpl( override fun setFixedPosition(destNum: Int, pos: Position) { val meshPos = - org.meshtastic.proto.Position( + ProtoPosition( latitude_i = Position.degI(pos.latitude), longitude_i = Position.degI(pos.longitude), altitude = pos.altitude, @@ -293,21 +294,17 @@ class CommandSenderImpl( if (type == TelemetryType.PAX) { portNum = PortNum.PAXCOUNTER_APP - payloadBytes = org.meshtastic.proto.Paxcount().encode().toByteString() + payloadBytes = Paxcount().encode().toByteString() } else { portNum = PortNum.TELEMETRY_APP payloadBytes = Telemetry( - device_metrics = - if (type == TelemetryType.DEVICE) org.meshtastic.proto.DeviceMetrics() else null, - environment_metrics = - if (type == TelemetryType.ENVIRONMENT) org.meshtastic.proto.EnvironmentMetrics() else null, - air_quality_metrics = - if (type == TelemetryType.AIR_QUALITY) org.meshtastic.proto.AirQualityMetrics() else null, - power_metrics = if (type == TelemetryType.POWER) org.meshtastic.proto.PowerMetrics() else null, - local_stats = - if (type == TelemetryType.LOCAL_STATS) org.meshtastic.proto.LocalStats() else null, - host_metrics = if (type == TelemetryType.HOST) org.meshtastic.proto.HostMetrics() else null, + device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics() else null, + environment_metrics = if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics() else null, + air_quality_metrics = if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics() else null, + power_metrics = if (type == TelemetryType.POWER) PowerMetrics() else null, + local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats() else null, + host_metrics = if (type == TelemetryType.HOST) HostMetrics() else null, ) .encode() .toByteString() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index 7f9e6c3fa..5fd34e02e 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import okio.ByteString import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single @@ -199,7 +200,7 @@ class MeshActionHandlerImpl( commandSender.sendData(p) serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) dataHandler.value.rememberDataPacket(p, myNodeNum, false) - val bytes = p.bytes ?: okio.ByteString.EMPTY + val bytes = p.bytes ?: ByteString.EMPTY analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType)) } @@ -356,7 +357,7 @@ class MeshActionHandlerImpl( override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA val otaEvent = - AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY) + AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY) commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index 918f25719..31e4f331d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import okio.ByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch @@ -221,7 +222,7 @@ class MeshConnectionManagerImpl( private fun tearDownConnection() { packetHandler.stopPacketQueue() - commandSender.setSessionPasskey(okio.ByteString.EMPTY) // Prevent stale passkey on reconnect. + commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect. locationManager.stop() mqttManager.stop() } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 5da0448b5..384f722d8 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import okio.ByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch @@ -247,7 +248,7 @@ class MeshDataHandlerImpl( val payload = packet.decoded?.payload ?: return val u = User.ADAPTER.decode(payload) - .let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it } + .let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } .let { if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) { it.copy(long_name = "${it.long_name} (MQTT)") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index 288ae9645..000d0b41d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -69,7 +69,7 @@ class MeshMessageProcessorImpl( @Volatile private var lastLocalNodeRefreshMs = 0L private val earlyMutex = Mutex() - private val earlyReceivedPackets = kotlin.collections.ArrayDeque() + private val earlyReceivedPackets = ArrayDeque() private val maxEarlyPacketBuffer = 10240 override fun clearEarlyPackets() { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index a5997208b..5d2feb65e 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -65,7 +65,7 @@ class TracerouteHandlerImpl( routeDiscovery.getTracerouteResponse( getUser = { num -> nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" } - ?: "Unknown" // TODO: Use core:resources once available in core:data + ?: "Unknown" }, headerTowards = "Route towards destination:", headerBack = "Route back to us:", diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index 55adf8b57..c6dfa7f43 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -132,13 +132,10 @@ class MeshConnectionManagerImplTest { scope, ) - @AfterTest fun tearDown() {} + @AfterTest fun tearDown() = Unit @Test fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) { - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - manager = createManager(backgroundScope) radioConnectionState.value = ConnectionState.Connected advanceUntilIdle() @@ -153,16 +150,6 @@ class MeshConnectionManagerImplTest { @Test fun `Disconnected state stops services`() = runTest(testDispatcher) { - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() manager = createManager(backgroundScope) // Transition to Connected first so that Disconnected actually does something @@ -191,11 +178,6 @@ class MeshConnectionManagerImplTest { device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT), ) every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() manager = createManager(backgroundScope) @@ -216,11 +198,6 @@ class MeshConnectionManagerImplTest { // Power saving enabled val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit manager = createManager(backgroundScope) advanceUntilIdle() @@ -280,11 +257,6 @@ class MeshConnectionManagerImplTest { device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER), ) every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() manager = createManager(backgroundScope) @@ -317,11 +289,6 @@ class MeshConnectionManagerImplTest { // Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected) val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() // Record every state transition so we can verify ordering @@ -367,11 +334,6 @@ class MeshConnectionManagerImplTest { // Power saving enabled with a short ls_secs so the sleep timeout fires quickly val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1)) every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { packetHandler.sendToRadio(any()) } returns Unit - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() val observed = mutableListOf() diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt index 0a1698c9a..e0bda6075 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt @@ -21,6 +21,7 @@ import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock +import dev.mokkery.verify import dev.mokkery.verifySuspend import io.kotest.property.Arb import io.kotest.property.arbitrary.int @@ -84,6 +85,8 @@ class PacketHandlerImplTest { val toRadio = ToRadio(packet = MeshPacket(id = 123)) handler.sendToRadio(toRadio) + + verify { radioInterfaceService.sendToRadio(any()) } } @Test @@ -93,6 +96,8 @@ class PacketHandlerImplTest { handler.sendToRadio(packet) testScheduler.runCurrent() + + verify { radioInterfaceService.sendToRadio(any()) } } @Test diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 25b9d812c..4e02ae2a7 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -34,7 +34,7 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Ability to mute notifications from specific nodes via admin messages. */ val canMuteNode = atLeast(V2_7_18) - /** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */ + /** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */ val canRequestNeighborInfo = atLeast(UNRELEASED) /** Ability to send verified shared contacts. Supported since firmware v2.7.12. */ diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt index 505f187ea..c8bbdadb5 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt @@ -16,16 +16,16 @@ */ package org.meshtastic.core.model -sealed class ConnectionState { +sealed interface ConnectionState { /** We are disconnected from the device, and we should be trying to reconnect. */ - data object Disconnected : ConnectionState() + data object Disconnected : ConnectionState /** We are currently attempting to connect to the device. */ - data object Connecting : ConnectionState() + data object Connecting : ConnectionState /** We are connected to the device and communicating normally. */ - data object Connected : ConnectionState() + data object Connected : ConnectionState /** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */ - data object DeviceSleep : ConnectionState() + data object DeviceSleep : ConnectionState } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt index 28eb2175d..426c6700b 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.network.radio import android.content.Context +import android.hardware.usb.UsbManager import android.provider.Settings import org.koin.core.annotation.Single import org.meshtastic.core.ble.BleConnectionFactory @@ -25,21 +26,23 @@ import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceType +import org.meshtastic.core.model.InterfaceId +import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory /** * Android implementation of [RadioTransportFactory]. Handles pure-KMP transports (BLE) via [BaseRadioTransportFactory] - * while delegating legacy platform-specific connections (like USB/Serial, TCP, and Mocks) to the Android-specific - * [InterfaceFactory]. + * while creating platform-specific connections (TCP, USB/Serial, Mock, NOP) directly in [createPlatformTransport]. */ @Single(binds = [RadioTransportFactory::class]) @Suppress("LongParameterList") class AndroidRadioTransportFactory( private val context: Context, - private val interfaceFactory: Lazy, private val buildConfigProvider: BuildConfigProvider, + private val usbRepository: UsbRepository, + private val usbManager: UsbManager, scanner: BleScanner, bluetoothRepository: BluetoothRepository, connectionFactory: BleConnectionFactory, @@ -48,13 +51,50 @@ class AndroidRadioTransportFactory( override val supportedDeviceTypes: List = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) - override fun isMockInterface(): Boolean = + override fun isMockTransport(): Boolean = buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true" - override fun isPlatformAddressValid(address: String): Boolean = interfaceFactory.value.addressValid(address) + override fun isPlatformAddressValid(address: String): Boolean { + val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } ?: return false + val rest = address.substring(1) + return when (interfaceId) { + InterfaceId.MOCK, + InterfaceId.NOP, + InterfaceId.TCP, + -> true + InterfaceId.SERIAL -> { + val deviceMap = usbRepository.serialDevices.value + val driver = deviceMap[rest] ?: deviceMap.values.firstOrNull() + driver != null && usbManager.hasPermission(driver.device) + } + InterfaceId.BLUETOOTH -> true // Handled by base class + } + } override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport { - // Fallback to legacy factory for Serial, Mocks, and NOPs - return interfaceFactory.value.createInterface(address, service) + val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } + val rest = address.substring(1) + + return when (interfaceId) { + InterfaceId.MOCK -> MockRadioTransport(callback = service, scope = service.serviceScope, address = rest) + InterfaceId.TCP -> + TcpRadioTransport( + callback = service, + scope = service.serviceScope, + dispatchers = dispatchers, + address = rest, + ) + InterfaceId.SERIAL -> + SerialRadioTransport( + callback = service, + scope = service.serviceScope, + usbRepository = usbRepository, + address = rest, + ) + InterfaceId.NOP, + null, + -> NopRadioTransport(rest) + InterfaceId.BLUETOOTH -> error("BLE addresses should be handled by BaseRadioTransportFactory") + } } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt deleted file mode 100644 index b070ba013..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.koin.core.annotation.Single -import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.RadioTransport - -/** - * Entry point for create radio backend instances given a specific address. - * - * This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest" - * of the address (which varies per implementation). - */ -@Single -class InterfaceFactory( - private val nopInterfaceFactory: NopInterfaceFactory, - private val mockSpec: Lazy, - private val serialSpec: Lazy, - private val tcpSpec: Lazy, -) { - internal val nopInterface by lazy { nopInterfaceFactory.create("") } - - private val specMap: Map> by lazy { - mapOf( - InterfaceId.MOCK to mockSpec.value, - InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory), - InterfaceId.SERIAL to serialSpec.value, - InterfaceId.TCP to tcpSpec.value, - ) - } - - fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest" - - fun createInterface(address: String, service: RadioInterfaceService): RadioTransport { - val (spec, rest) = splitAddress(address) - return spec?.createInterface(rest, service) ?: nopInterface - } - - fun addressValid(address: String?): Boolean = address?.let { - val (spec, rest) = splitAddress(it) - spec?.addressValid(rest) - } ?: false - - private fun splitAddress(address: String): Pair?, String> { - if (address.isEmpty()) return Pair(null, "") - val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it] } - val rest = address.substring(1) - return Pair(c, rest) - } -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt deleted file mode 100644 index f8c53313b..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.koin.core.annotation.Single -import org.meshtastic.core.network.repository.UsbRepository -import org.meshtastic.core.repository.RadioInterfaceService - -/** Factory for creating `SerialInterface` instances. */ -@Single -class SerialInterfaceFactory(private val usbRepository: UsbRepository) { - fun create(rest: String, service: RadioInterfaceService): SerialInterface = - SerialInterface(service, usbRepository, rest) -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt deleted file mode 100644 index f510be3bb..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import android.hardware.usb.UsbManager -import com.hoho.android.usbserial.driver.UsbSerialDriver -import org.koin.core.annotation.Single -import org.meshtastic.core.network.repository.UsbRepository -import org.meshtastic.core.repository.RadioInterfaceService - -/** Serial/USB interface backend implementation. */ -@Single -class SerialInterfaceSpec( - private val factory: SerialInterfaceFactory, - private val usbManager: UsbManager, - private val usbRepository: UsbRepository, -) : InterfaceSpec { - override fun createInterface(rest: String, service: RadioInterfaceService): SerialInterface = - factory.create(rest, service) - - override fun addressValid(rest: String): Boolean { - val driver = findSerial(rest) ?: return false - return usbManager.hasPermission(driver.device) - } - - internal fun findSerial(rest: String): UsbSerialDriver? { - val deviceMap = usbRepository.serialDevices.value - return deviceMap[rest] ?: deviceMap.values.firstOrNull() - } -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt similarity index 83% rename from core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt rename to core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt index 6c843caee..bc3558800 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt @@ -17,24 +17,28 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.network.repository.SerialConnection import org.meshtastic.core.network.repository.SerialConnectionListener import org.meshtastic.core.network.repository.UsbRepository -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.proto.Heartbeat -import org.meshtastic.proto.ToRadio +import org.meshtastic.core.network.transport.HeartbeatSender +import org.meshtastic.core.repository.RadioTransportCallback import java.util.concurrent.atomic.AtomicReference -/** An interface that assumes we are talking to a meshtastic device via USB serial */ -class SerialInterface( - service: RadioInterfaceService, +/** An Android USB/serial [RadioTransport] implementation. */ +class SerialRadioTransport( + callback: RadioTransportCallback, + scope: CoroutineScope, private val usbRepository: UsbRepository, private val address: String, -) : StreamInterface(service) { +) : StreamTransport(callback, scope) { private var connRef = AtomicReference() - init { + private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]") + + override fun start() { connect() } @@ -116,14 +120,9 @@ class SerialInterface( } override fun keepAlive() { - // Send a ToRadio heartbeat so the firmware resets its idle timer and responds with - // a FromRadio queueStatus — proving the serial link is alive. Without this, the - // serial transport has no way to detect a silently dead device (battery depleted, - // firmware crash without the `rebooted` flag). The queueStatus response also feeds - // into MeshMessageProcessorImpl.refreshLocalNodeLastHeard() to keep the local - // node's lastHeard timestamp current. - Logger.d { "[$address] Serial keepAlive — sending heartbeat" } - handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode()) + // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the serial + // link is alive and keep the local node's lastHeard timestamp current. + scope.handledLaunch { heartbeatSender.sendHeartbeat() } } override fun sendBytes(p: ByteArray) { diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt deleted file mode 100644 index 003294448..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.repository.RadioInterfaceService - -/** Factory for creating `TCPInterface` instances. */ -@Single -class TCPInterfaceFactory(private val dispatchers: CoroutineDispatchers) { - fun create(rest: String, service: RadioInterfaceService): TCPInterface = TCPInterface(service, dispatchers, rest) -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt deleted file mode 100644 index 2539bc13c..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.RadioInterfaceService - -/** TCP interface backend implementation. */ -@Single -class TCPInterfaceSpec(private val factory: TCPInterfaceFactory) : InterfaceSpec { - override fun createInterface(rest: String, service: RadioInterfaceService): TCPInterface = - factory.create(rest, service) -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt index 2c5a02784..55856abf9 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt @@ -38,40 +38,41 @@ abstract class BaseRadioTransportFactory( override fun isAddressValid(address: String?): Boolean { val spec = address?.firstOrNull() ?: return false - return spec in - listOf(InterfaceId.TCP.id, InterfaceId.SERIAL.id, InterfaceId.BLUETOOTH.id, InterfaceId.MOCK.id) || - spec == '!' || - isPlatformAddressValid(address) + return when (spec) { + InterfaceId.TCP.id, + InterfaceId.SERIAL.id, + InterfaceId.BLUETOOTH.id, + InterfaceId.MOCK.id, + '!', + -> true + else -> isPlatformAddressValid(address) + } } protected open fun isPlatformAddressValid(address: String): Boolean = false override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest" - override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport = when { - address.startsWith(InterfaceId.BLUETOOTH.id) -> { - BleRadioInterface( - serviceScope = service.serviceScope, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - service = service, - address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()), - ) - } - address.startsWith("!") -> { - BleRadioInterface( - serviceScope = service.serviceScope, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - service = service, - address = address.removePrefix("!"), - ) - } - else -> createPlatformTransport(address, service) + override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport { + val transport = + when { + address.startsWith(InterfaceId.BLUETOOTH.id) || address.startsWith("!") -> { + val bleAddress = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()).removePrefix("!") + BleRadioTransport( + scope = service.serviceScope, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = bleAddress, + ) + } + else -> createPlatformTransport(address, service) + } + transport.start() + return transport } - /** Delegate to platform for Mock, TCP, or Serial/USB interfaces. */ + /** Delegate to platform for Mock, TCP, or Serial/USB transports. */ protected abstract fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt similarity index 52% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt index 2eda52102..cfc84c668 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -32,7 +33,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -47,54 +47,22 @@ import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.ble.DisconnectReason import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID +import org.meshtastic.core.ble.MeshtasticRadioProfile import org.meshtastic.core.ble.classifyBleException import org.meshtastic.core.ble.retryBleOperation import org.meshtastic.core.ble.toMeshtasticRadioProfile import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.RadioNotConnectedException -import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.network.transport.HeartbeatSender import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.proto.Heartbeat -import org.meshtastic.proto.ToRadio +import org.meshtastic.core.repository.RadioTransportCallback import kotlin.concurrent.Volatile -import kotlin.concurrent.atomics.AtomicInt -import kotlin.concurrent.atomics.ExperimentalAtomicApi -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds private const val SCAN_RETRY_COUNT = 3 private val SCAN_RETRY_DELAY = 1.seconds private val CONNECTION_TIMEOUT = 15.seconds -private const val RECONNECT_FAILURE_THRESHOLD = 3 -private val RECONNECT_BASE_DELAY = 5.seconds -private val RECONNECT_MAX_DELAY = 60.seconds -private const val RECONNECT_MAX_FAILURES = 10 - -/** Settle delay before each connection attempt to let the Android BLE stack finish any pending disconnect cleanup. */ -private val SETTLE_DELAY = 1.seconds - -/** - * Minimum time a BLE connection must stay up before we consider it "stable" and reset - * [BleRadioInterface.consecutiveFailures]. Without this, a device at the edge of BLE range can repeatedly connect for a - * fraction of a second and drop — each brief connection resets the failure counter so [RECONNECT_FAILURE_THRESHOLD] is - * never reached, and the app never signals [ConnectionState.DeviceSleep]. - * - * The value (5 s) is long enough that only connections that survive past the initial GATT setup are treated as genuine, - * but short enough that normal reconnects after light-sleep still reset the counter promptly. - */ -private val MIN_STABLE_CONNECTION = 5.seconds - -/** - * Returns the reconnect backoff delay for a given consecutive failure count. - * - * Backoff schedule: 1 failure → 5 s 2 failures → 10 s 3 failures → 20 s 4 failures → 40 s 5+ failures → 60 s (capped) - */ -internal fun computeReconnectBackoff(consecutiveFailures: Int): Duration { - if (consecutiveFailures <= 0) return RECONNECT_BASE_DELAY - val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(4) - return minOf(RECONNECT_BASE_DELAY * multiplier, RECONNECT_MAX_DELAY) -} /** * Delay after writing a heartbeat before re-polling FROMRADIO. @@ -117,27 +85,27 @@ private val GATT_CLEANUP_TIMEOUT = 5.seconds * - Bonding and discovery. * - Automatic reconnection logic. * - MTU and connection parameter monitoring. - * - Routing raw byte packets between the radio and [RadioInterfaceService]. + * - Routing raw byte packets between the radio and [RadioTransportCallback]. * - * @param serviceScope The coroutine scope to use for launching coroutines. + * @param scope The coroutine scope to use for launching coroutines. * @param scanner The BLE scanner. * @param bluetoothRepository The Bluetooth repository. * @param connectionFactory The BLE connection factory. - * @param service The [RadioInterfaceService] to use for handling radio events. + * @param callback The [RadioTransportCallback] to use for handling radio events. * @param address The BLE address of the device to connect to. */ -class BleRadioInterface( - private val serviceScope: CoroutineScope, +class BleRadioTransport( + private val scope: CoroutineScope, private val scanner: BleScanner, private val bluetoothRepository: BluetoothRepository, private val connectionFactory: BleConnectionFactory, - private val service: RadioInterfaceService, + private val callback: RadioTransportCallback, internal val address: String, ) : RadioTransport { private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" } - serviceScope.launch { + scope.launch { try { bleConnection.disconnect() } catch (e: Exception) { @@ -145,13 +113,11 @@ class BleRadioInterface( } } val (isPermanent, msg) = throwable.toDisconnectReason() - service.onDisconnect(isPermanent, errorMessage = msg) + callback.onDisconnect(isPermanent, errorMessage = msg) } private val connectionScope: CoroutineScope = - CoroutineScope( - serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler, - ) + CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + exceptionHandler) private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address) private val writeMutex: Mutex = Mutex() @@ -167,12 +133,19 @@ class BleRadioInterface( @Volatile private var isFullyConnected = false private var connectionJob: Job? = null - private var consecutiveFailures = 0 + private val reconnectPolicy = BleReconnectPolicy() - @OptIn(ExperimentalAtomicApi::class) - private val heartbeatNonce = AtomicInt(0) + private val heartbeatSender = + HeartbeatSender( + sendToRadio = ::handleSendToRadio, + afterHeartbeat = { + delay(HEARTBEAT_DRAIN_DELAY) + radioService?.requestDrain() + }, + logTag = address, + ) - init { + override fun start() { connect() } @@ -209,134 +182,104 @@ class BleRadioInterface( throw RadioNotConnectedException("Device not found at address $address") } - @Suppress("LongMethod", "CyclomaticComplexMethod") private fun connect() { connectionJob = connectionScope.launch { - while (isActive) { - try { - // Settle delay: let the Android BLE stack finish any pending - // disconnect cleanup before starting a new connection attempt. - delay(SETTLE_DELAY) - - connectionStartTime = nowMillis - Logger.i { "[$address] BLE connection attempt started" } - - val device = findDevice() - - // Bond before connecting: firmware may require an encrypted link, - // and without a bond Android fails with status 5 or 133. - // No-op on Desktop/JVM where the OS handles pairing automatically. - if (!bluetoothRepository.isBonded(address)) { - Logger.i { "[$address] Device not bonded, initiating bonding" } - @Suppress("TooGenericExceptionCaught") - try { - bluetoothRepository.bond(device) - Logger.i { "[$address] Bonding successful" } - } catch (e: Exception) { - Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" } - } + reconnectPolicy.execute( + attempt = { + try { + attemptConnection() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val failureTime = (nowMillis - connectionStartTime).milliseconds + Logger.w(e) { "[$address] Failed to connect after $failureTime" } + BleReconnectPolicy.Outcome.Failed(e) } + }, + onTransientDisconnect = { error -> + val msg = error?.toDisconnectReason()?.second ?: "Device unreachable" + callback.onDisconnect(isPermanent = false, errorMessage = msg) + }, + onPermanentDisconnect = { error -> + val msg = error?.toDisconnectReason()?.second ?: "Device unreachable" + callback.onDisconnect(isPermanent = true, errorMessage = msg) + }, + ) + } + } - val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) + /** + * Performs a single BLE connect-and-wait cycle. + * + * Finds the device, bonds if needed, connects, discovers services, and waits for disconnect. Returns a + * [BleReconnectPolicy.Outcome] describing how the connection ended. + */ + @Suppress("CyclomaticComplexMethod") + private suspend fun attemptConnection(): BleReconnectPolicy.Outcome { + connectionStartTime = nowMillis + Logger.i { "[$address] BLE connection attempt started" } - if (state !is BleConnectionState.Connected) { - throw RadioNotConnectedException("Failed to connect to device at address $address") - } + val device = findDevice() - // Only reset failures if connection was stable (see MIN_STABLE_CONNECTION). - val gattConnectedAt = nowMillis - isFullyConnected = true - onConnected() + // Bond before connecting: firmware may require an encrypted link, + // and without a bond Android fails with status 5 or 133. + // No-op on Desktop/JVM where the OS handles pairing automatically. + if (!bluetoothRepository.isBonded(address)) { + Logger.i { "[$address] Device not bonded, initiating bonding" } + @Suppress("TooGenericExceptionCaught") + try { + bluetoothRepository.bond(device) + Logger.i { "[$address] Bonding successful" } + } catch (e: Exception) { + Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" } + } + } - // Scope the connectionState listener to this iteration so it's - // cancelled automatically before the next reconnect cycle. - var disconnectReason: DisconnectReason = DisconnectReason.Unknown - coroutineScope { - bleConnection.connectionState - .onEach { s -> - if (s is BleConnectionState.Disconnected && isFullyConnected) { - isFullyConnected = false - disconnectReason = s.reason - onDisconnected() - } - } - .catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } } - .launchIn(this) + val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) - discoverServicesAndSetupCharacteristics() + if (state !is BleConnectionState.Connected) { + throw RadioNotConnectedException("Failed to connect to device at address $address") + } - bleConnection.connectionState.first { it is BleConnectionState.Disconnected } - } + val gattConnectedAt = nowMillis + isFullyConnected = true + onConnected() - Logger.i { - "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" - } - - // Skip failure counting for intentional disconnects. - if (disconnectReason is DisconnectReason.LocalDisconnect) { - consecutiveFailures = 0 - continue - } - - // A connection that drops almost immediately (< MIN_STABLE_CONNECTION) - // is treated as a failure — the BLE stack may have "connected" to a - // cached GATT profile before realising the device is gone. - val connectionUptime = (nowMillis - gattConnectedAt).milliseconds - if (connectionUptime >= MIN_STABLE_CONNECTION) { - consecutiveFailures = 0 - } else { - consecutiveFailures++ - Logger.w { - "[$address] Connection lasted only $connectionUptime " + - "(< $MIN_STABLE_CONNECTION) — treating as failure " + - "(consecutive failures: $consecutiveFailures)" - } - if (consecutiveFailures >= RECONNECT_MAX_FAILURES) { - Logger.e { "[$address] Giving up after $consecutiveFailures unstable connections" } - service.onDisconnect( - isPermanent = true, - errorMessage = "Device unreachable (unstable connection)", - ) - return@launch - } - if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) { - service.onDisconnect( - isPermanent = false, - errorMessage = "Device unreachable (unstable connection)", - ) - } - } - } catch (e: kotlinx.coroutines.CancellationException) { - Logger.d { "[$address] BLE connection coroutine cancelled" } - throw e - } catch (e: Exception) { - val failureTime = (nowMillis - connectionStartTime).milliseconds - consecutiveFailures++ - Logger.w(e) { - "[$address] Failed to connect to device after $failureTime " + - "(consecutive failures: $consecutiveFailures)" - } - - // Give up permanently to stop draining battery. - if (consecutiveFailures >= RECONNECT_MAX_FAILURES) { - Logger.e { "[$address] Giving up after $consecutiveFailures consecutive failures" } - val (_, msg) = e.toDisconnectReason() - service.onDisconnect(isPermanent = true, errorMessage = msg) - return@launch - } - - // Signal DeviceSleep so MeshConnectionManagerImpl starts its sleep timeout. - if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) { - handleFailure(e) - } - - val backoff = computeReconnectBackoff(consecutiveFailures) - Logger.d { "[$address] Retrying in $backoff (failure #$consecutiveFailures)" } - delay(backoff) + // Scope the connectionState listener to this iteration so it's + // cancelled automatically before the next reconnect cycle. + var disconnectReason: DisconnectReason = DisconnectReason.Unknown + coroutineScope { + bleConnection.connectionState + .onEach { s -> + if (s is BleConnectionState.Disconnected && isFullyConnected) { + isFullyConnected = false + disconnectReason = s.reason + onDisconnected() } } + .catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } } + .launchIn(this) + + discoverServicesAndSetupCharacteristics() + + bleConnection.connectionState.first { it is BleConnectionState.Disconnected } + } + + Logger.i { "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" } + + val wasIntentional = disconnectReason is DisconnectReason.LocalDisconnect + val connectionUptime = (nowMillis - gattConnectedAt).milliseconds + val wasStable = connectionUptime >= reconnectPolicy.minStableConnection + + if (!wasStable && !wasIntentional) { + Logger.w { + "[$address] Connection lasted only $connectionUptime " + + "(< ${reconnectPolicy.minStableConnection}) — treating as unstable" } + } + + return BleReconnectPolicy.Outcome.Disconnected(wasStable = wasStable, wasIntentional = wasIntentional) } private suspend fun onConnected() { @@ -354,7 +297,7 @@ class BleRadioInterface( radioService = null Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" } // Signal immediately so the UI reflects the disconnect while reconnect continues. - service.onDisconnect(isPermanent = false) + callback.onDisconnect(isPermanent = false) } private suspend fun discoverServicesAndSetupCharacteristics() { @@ -384,7 +327,7 @@ class BleRadioInterface( } .launchIn(this) - this@BleRadioInterface.radioService = radioService + this@BleRadioTransport.radioService = radioService Logger.i { "[$address] Profile service active and characteristics subscribed" } @@ -395,7 +338,7 @@ class BleRadioInterface( val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" } - this@BleRadioInterface.service.onConnect() + this@BleRadioTransport.callback.onConnect() } } catch (e: Exception) { Logger.w(e) { "[$address] Profile service discovery or operation failed" } @@ -409,7 +352,7 @@ class BleRadioInterface( } } - @Volatile private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null + @Volatile private var radioService: MeshtasticRadioProfile? = null // --- RadioTransport Implementation --- @@ -445,36 +388,19 @@ class BleRadioInterface( } } - @OptIn(ExperimentalAtomicApi::class) override fun keepAlive() { - // Send a ToRadio heartbeat so the firmware resets its power-saving idle timer. - // The firmware only resets the timer on writes to the TORADIO characteristic; a - // BLE-level GATT keepalive is invisible to it. Without this the device may enter - // light-sleep and drop the BLE connection after ~60 s of application inactivity. - // - // Each heartbeat uses a distinct nonce to vary the wire bytes, preventing the - // firmware's per-connection duplicate-write filter from silently dropping it. - val nonce = heartbeatNonce.fetchAndAdd(1) - Logger.v { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" } - handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode()) - - // The firmware responds to heartbeats by queuing a `queueStatus` FromRadio packet - // on the next getFromRadio() call, but it does NOT send a FROMNUM notification for - // it. The immediate drain trigger in sendToRadio() fires before the ESP32's async - // task queue has processed the heartbeat, so the response sits unread. Schedule a - // delayed re-drain to pick it up. - connectionScope.launch { - delay(HEARTBEAT_DRAIN_DELAY) - radioService?.requestDrain() - } + // Delegate to HeartbeatSender which sends a ToRadio heartbeat with a unique nonce + // so the firmware resets its power-saving idle timer. After sending, it schedules + // a delayed re-drain to pick up the queueStatus response. + connectionScope.launch { heartbeatSender.sendHeartbeat() } } /** Closes the connection to the device. */ override fun close() { Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" } connectionScope.cancel("close() called") - // GATT cleanup must outlive serviceScope cancellation — GlobalScope is intentional. - // SharedRadioInterfaceService cancels serviceScope immediately after close(), so a + // GATT cleanup must outlive scope cancellation — GlobalScope is intentional. + // SharedRadioInterfaceService cancels the scope immediately after close(), so a // coroutine launched there may never run, leaking BluetoothGatt (causes GATT 133). @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch { @@ -493,12 +419,12 @@ class BleRadioInterface( "[$address] Dispatching packet #$packetsReceived " + "(${packet.size} bytes, total RX: $bytesReceived bytes)" } - service.handleFromRadio(packet) + callback.handleFromRadio(packet) } private fun handleFailure(throwable: Throwable) { val (isPermanent, msg) = throwable.toDisconnectReason() - service.onDisconnect(isPermanent, errorMessage = msg) + callback.onDisconnect(isPermanent, errorMessage = msg) } /** Formats a one-line session statistics summary for logging. */ diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt new file mode 100644 index 000000000..cef746af0 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Encapsulates the BLE reconnection policy with exponential backoff. + * + * The policy tracks consecutive failures and decides whether to retry, signal a transient disconnect (DeviceSleep), or + * give up permanently. + * + * @param maxFailures maximum consecutive failures before giving up permanently + * @param failureThreshold after this many consecutive failures, signal a transient disconnect + * @param settleDelay delay before each connection attempt to let the BLE stack settle + * @param minStableConnection minimum time a connection must stay up to be considered "stable" + * @param backoffStrategy computes the backoff delay for a given failure count + */ +class BleReconnectPolicy( + private val maxFailures: Int = DEFAULT_MAX_FAILURES, + private val failureThreshold: Int = DEFAULT_FAILURE_THRESHOLD, + private val settleDelay: Duration = DEFAULT_SETTLE_DELAY, + /** Minimum time a connection must stay up to be considered "stable". Exposed for callers to compare uptime. */ + val minStableConnection: Duration = DEFAULT_MIN_STABLE_CONNECTION, + private val backoffStrategy: (attempt: Int) -> Duration = ::computeReconnectBackoff, +) { + /** Outcome of a single reconnect iteration. */ + sealed interface Outcome { + /** Connection attempt succeeded and then eventually disconnected. */ + data class Disconnected(val wasStable: Boolean, val wasIntentional: Boolean) : Outcome + + /** Connection attempt failed with an exception. */ + data class Failed(val error: Throwable) : Outcome + } + + /** Action the caller should take after the policy processes an outcome. */ + sealed interface Action { + /** Retry the connection after the specified backoff delay. */ + data class Retry(val backoff: Duration) : Action + + /** Signal a transient disconnect to higher layers. */ + data class SignalTransient(val backoff: Duration) : Action + + /** Give up permanently. */ + data object GiveUp : Action + + /** Continue immediately (e.g. after an intentional disconnect). */ + data object Continue : Action + } + + internal var consecutiveFailures: Int = 0 + private set + + /** Processes the outcome of a connection attempt and returns the action the caller should take. */ + fun processOutcome(outcome: Outcome): Action = when (outcome) { + is Outcome.Disconnected -> { + if (outcome.wasIntentional) { + consecutiveFailures = 0 + Action.Continue + } else if (outcome.wasStable) { + consecutiveFailures = 0 + Action.Continue + } else { + consecutiveFailures++ + Logger.w { "Unstable connection (consecutive failures: $consecutiveFailures)" } + evaluateFailure() + } + } + is Outcome.Failed -> { + consecutiveFailures++ + Logger.w { "Connection failed (consecutive failures: $consecutiveFailures)" } + evaluateFailure() + } + } + + private fun evaluateFailure(): Action { + if (consecutiveFailures >= maxFailures) { + return Action.GiveUp + } + val backoff = backoffStrategy(consecutiveFailures) + return if (consecutiveFailures >= failureThreshold) { + Action.SignalTransient(backoff) + } else { + Action.Retry(backoff) + } + } + + /** + * Runs the reconnect loop, calling [attempt] for each iteration. + * + * The [attempt] lambda should perform a single connect-and-wait cycle and return the [Outcome] when the connection + * drops or an error occurs. + * + * @param attempt performs a single connection attempt and returns the outcome + * @param onTransientDisconnect called when the policy decides to signal a transient disconnect + * @param onPermanentDisconnect called when the policy gives up permanently + */ + suspend fun execute( + attempt: suspend () -> Outcome, + onTransientDisconnect: suspend (Throwable?) -> Unit, + onPermanentDisconnect: suspend (Throwable?) -> Unit, + ) { + while (coroutineContext.isActive) { + delay(settleDelay) + + val outcome = attempt() + val lastError = (outcome as? Outcome.Failed)?.error + + when (val action = processOutcome(outcome)) { + is Action.Continue -> continue + is Action.Retry -> { + Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" } + delay(action.backoff) + } + is Action.SignalTransient -> { + onTransientDisconnect(lastError) + Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" } + delay(action.backoff) + } + is Action.GiveUp -> { + Logger.e { "Giving up after $consecutiveFailures consecutive failures" } + onPermanentDisconnect(lastError) + return + } + } + } + } + + companion object { + const val DEFAULT_MAX_FAILURES = 10 + const val DEFAULT_FAILURE_THRESHOLD = 3 + val DEFAULT_SETTLE_DELAY = 1.seconds + val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds + + internal val RECONNECT_BASE_DELAY = 5.seconds + internal val RECONNECT_MAX_DELAY = 60.seconds + internal const val BACKOFF_MAX_EXPONENT = 4 + } +} + +/** + * Returns the reconnect backoff delay for a given consecutive failure count. + * + * Backoff schedule: 1 failure → 5 s, 2 failures → 10 s, 3 failures → 20 s, 4 failures → 40 s, 5+ failures → 60 s + * (capped). + */ +internal fun computeReconnectBackoff(consecutiveFailures: Int): Duration { + if (consecutiveFailures <= 0) return BleReconnectPolicy.RECONNECT_BASE_DELAY + val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(BleReconnectPolicy.BACKOFF_MAX_EXPONENT) + return minOf(BleReconnectPolicy.RECONNECT_BASE_DELAY * multiplier, BleReconnectPolicy.RECONNECT_MAX_DELAY) +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt deleted file mode 100644 index aec9ec667..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.RadioTransport - -/** This interface defines the contract that all radio backend implementations must adhere to. */ -interface InterfaceSpec { - fun createInterface(rest: String, service: RadioInterfaceService): T - - /** Return true if this address is still acceptable. For BLE that means, still bonded */ - fun addressValid(rest: String): Boolean = true -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt deleted file mode 100644 index 492b5782c..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.RadioInterfaceService - -/** Factory for creating `MockInterface` instances. */ -@Single -class MockInterfaceFactory { - fun create(rest: String, service: RadioInterfaceService): MockInterface = MockInterface(service, rest) -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceSpec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceSpec.kt deleted file mode 100644 index 0f77cb5dc..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceSpec.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.RadioInterfaceService - -/** Mock interface backend implementation. */ -@Single -class MockInterfaceSpec(private val factory: MockInterfaceFactory) : InterfaceSpec { - override fun createInterface(rest: String, service: RadioInterfaceService): MockInterface = - factory.create(rest, service) - - /** Return true if this address is still acceptable. For BLE that means, still bonded */ - override fun addressValid(rest: String): Boolean = true -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt similarity index 90% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt index 4990ee7ab..78d3d4ceb 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import okio.ByteString.Companion.encodeUtf8 import okio.ByteString.Companion.toByteString @@ -25,8 +26,8 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Channel import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.util.getInitials -import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport +import org.meshtastic.core.repository.RadioTransportCallback import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Config import org.meshtastic.proto.Data @@ -55,9 +56,13 @@ private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Co private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY) -/** A simulated interface that is used for testing in the simulator */ +/** A simulated transport that is used for testing in the simulator. */ @Suppress("detekt:TooManyFunctions", "detekt:MagicNumber") -class MockInterface(private val service: RadioInterfaceService, val address: String) : RadioTransport { +class MockRadioTransport( + private val callback: RadioTransportCallback, + private val scope: CoroutineScope, + val address: String, +) : RadioTransport { companion object { private const val MY_NODE = 0x42424242 @@ -68,13 +73,22 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str // an infinite sequence of ints private val packetIdSequence = generateSequence { currentPacketId++ }.iterator() - init { - Logger.i { "Starting the mock interface" } - service.onConnect() // Tell clients they can use the API + override fun start() { + Logger.i { "Starting the mock transport" } + callback.onConnect() // Tell clients they can use the API } override fun handleSendToRadio(p: ByteArray) { val pr = ToRadio.ADAPTER.decode(p) + + // Intercept want_config handshake — send config response only when requested, + // mirroring the behaviour of real firmware which waits for want_config_id. + val wantConfigId = pr.want_config_id ?: 0 + if (wantConfigId != 0) { + sendConfigResponse(wantConfigId) + return + } + val packet = pr.packet if (packet != null) { sendQueueStatus(packet.id) @@ -83,11 +97,10 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str val data = packet?.decoded when { - (pr.want_config_id ?: 0) != 0 -> sendConfigResponse(pr.want_config_id ?: 0) data != null && data.portnum == PortNum.ADMIN_APP -> handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload)) packet != null && packet.want_ack == true -> sendFakeAck(pr) - else -> Logger.i { "Ignoring data sent to mock interface $pr" } + else -> Logger.i { "Ignoring data sent to mock transport $pr" } } } @@ -127,12 +140,12 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str ) } - else -> Logger.i { "Ignoring admin sent to mock interface $d" } + else -> Logger.i { "Ignoring admin sent to mock transport $d" } } } override fun close() { - Logger.i { "Closing the mock interface" } + Logger.i { "Closing the mock transport" } } // / Generate a fake text message from a node @@ -279,7 +292,7 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId), ) - private fun sendQueueStatus(msgId: Int) = service.handleFromRadio( + private fun sendQueueStatus(msgId: Int) = callback.handleFromRadio( FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(), ) @@ -291,14 +304,14 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str toIn, Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId), ) - service.handleFromRadio(p.encode()) + callback.handleFromRadio(p.encode()) } // / Send a fake ack packet back if the sender asked for want_ack - private fun sendFakeAck(pr: ToRadio) = service.serviceScope.handledLaunch { + private fun sendFakeAck(pr: ToRadio) = scope.handledLaunch { val packet = pr.packet ?: return@handledLaunch delay(2000) - service.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) + callback.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) } private fun sendConfigResponse(configId: Int) { @@ -353,6 +366,6 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str makeNodeStatus(MY_NODE + 1), ) - packets.forEach { p -> service.handleFromRadio(p.encode()) } + packets.forEach { p -> callback.handleFromRadio(p.encode()) } } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt deleted file mode 100644 index 5d9991e34..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.koin.core.annotation.Single - -/** Factory for creating `NopInterface` instances. */ -@Single -class NopInterfaceFactory { - fun create(rest: String): NopInterface = NopInterface(rest) -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceSpec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceSpec.kt deleted file mode 100644 index df77578bf..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceSpec.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.RadioInterfaceService - -/** No-op interface backend implementation. */ -@Single -class NopInterfaceSpec(private val factory: NopInterfaceFactory) : InterfaceSpec { - override fun createInterface(rest: String, service: RadioInterfaceService): NopInterface = factory.create(rest) -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt similarity index 69% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterface.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt index 27348635c..db807081a 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt @@ -18,7 +18,14 @@ package org.meshtastic.core.network.radio import org.meshtastic.core.repository.RadioTransport -class NopInterface(val address: String) : RadioTransport { +/** + * An intentionally inert [RadioTransport] that silently discards all operations. + * + * Used as a safe default when no valid device address is configured or when the requested transport type is + * unsupported. All method calls are no-ops — it never connects, never sends data, and never signals lifecycle events to + * the service layer. + */ +class NopRadioTransport(val address: String) : RadioTransport { override fun handleSendToRadio(p: ByteArray) { // No-op } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt similarity index 66% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt index d72c9d0d5..ff2e5e33e 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt @@ -17,10 +17,11 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.network.transport.StreamFrameCodec -import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport +import org.meshtastic.core.repository.RadioTransportCallback /** * An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP @@ -28,9 +29,11 @@ import org.meshtastic.core.repository.RadioTransport * * Delegates framing logic to [StreamFrameCodec] from `core:network`. */ -abstract class StreamInterface(protected val service: RadioInterfaceService) : RadioTransport { +abstract class StreamTransport(protected val callback: RadioTransportCallback, protected val scope: CoroutineScope) : + RadioTransport { - private val codec = StreamFrameCodec(onPacketReceived = { service.handleFromRadio(it) }, logTag = "StreamInterface") + private val codec = + StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport") override fun close() { Logger.d { "Closing stream for good" } @@ -38,33 +41,34 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) : R } /** - * Tell MeshService our device has gone away, but wait for it to come back + * Notify the transport callback that our device has gone away, but wait for it to come back. * - * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the - * manager callbacks + * @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside + * transport callbacks * @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g. - * TCP transient disconnect). Defaults to true for serial — subclasses like [TCPInterface] override with false. + * TCP transient disconnect). Defaults to true for serial — subclasses may override with false. */ protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) { - service.onDisconnect(isPermanent = isPermanent) + callback.onDisconnect(isPermanent = isPermanent) } protected open fun connect() { - // Before telling mesh service, send a few START1s to wake a sleeping device + // Before connecting, send a few START1s to wake a sleeping device sendBytes(StreamFrameCodec.WAKE_BYTES) // Now tell clients they can (finally use the api) - service.onConnect() + callback.onConnect() } + /** Writes raw bytes to the underlying stream (serial port, TCP socket, etc.). */ abstract fun sendBytes(p: ByteArray) - // If subclasses need to flush at the end of a packet they can implement + /** Flushes buffered bytes to the underlying stream. No-op by default. */ open fun flushBytes() {} override fun handleSendToRadio(p: ByteArray) { // This method is called from a continuation and it might show up late, so check for uart being null - service.serviceScope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) } + scope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) } } /** Process a single incoming byte through the stream framing state machine. */ diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index 6be47c8eb..5e4ffa91d 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -18,12 +18,15 @@ package org.meshtastic.core.network.repository import co.touchlab.kermit.Logger import io.github.davidepianca98.MQTTClient +import io.github.davidepianca98.mqtt.MQTTException import io.github.davidepianca98.mqtt.MQTTVersion import io.github.davidepianca98.mqtt.Subscription import io.github.davidepianca98.mqtt.packets.Qos import io.github.davidepianca98.mqtt.packets.mqttv5.ReasonCode import io.github.davidepianca98.mqtt.packets.mqttv5.SubscriptionOptions +import io.github.davidepianca98.socket.IOException import io.github.davidepianca98.socket.tls.TLSClientSettings +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -36,9 +39,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecodingException import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MqttJsonPayload import org.meshtastic.core.model.util.subscribeList import org.meshtastic.core.repository.NodeRepository @@ -50,7 +56,7 @@ import kotlin.concurrent.Volatile class MQTTRepositoryImpl( private val radioConfigRepository: RadioConfigRepository, private val nodeRepository: NodeRepository, - dispatchers: org.meshtastic.core.di.CoroutineDispatchers, + dispatchers: CoroutineDispatchers, ) : MQTTRepository { companion object { @@ -78,14 +84,15 @@ class MQTTRepositoryImpl( @Suppress("TooGenericExceptionCaught") override fun disconnect() { Logger.i { "MQTT Disconnecting" } + val c = client + client = null // Null first to prevent re-entrant disconnect try { - client?.disconnect(ReasonCode.SUCCESS) + c?.disconnect(ReasonCode.SUCCESS) } catch (e: Exception) { Logger.w(e) { "MQTT clean disconnect failed" } } clientJob?.cancel() clientJob = null - client = null } @OptIn(ExperimentalUnsignedTypes::class) @@ -123,10 +130,10 @@ class MQTTRepositoryImpl( Logger.d { "MQTT parsed JSON payload successfully" } trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = packet.retain)) - } catch (e: kotlinx.serialization.json.JsonDecodingException) { + } catch (e: JsonDecodingException) { @OptIn(ExperimentalSerializationApi::class) Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" } - } catch (e: kotlinx.serialization.SerializationException) { + } catch (e: SerializationException) { Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } } catch (e: IllegalArgumentException) { Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } @@ -180,11 +187,11 @@ class MQTTRepositoryImpl( // Reset backoff so the next reconnect starts with the minimum delay. reconnectDelay = INITIAL_RECONNECT_DELAY_MS Logger.w { "MQTT client loop ended normally, reconnecting in ${reconnectDelay}ms" } - } catch (e: io.github.davidepianca98.mqtt.MQTTException) { + } catch (e: MQTTException) { Logger.e(e) { "MQTT Client loop error (MQTT), reconnecting in ${reconnectDelay}ms" } - } catch (e: io.github.davidepianca98.socket.IOException) { + } catch (e: IOException) { Logger.e(e) { "MQTT Client loop error (IO), reconnecting in ${reconnectDelay}ms" } - } catch (e: kotlinx.coroutines.CancellationException) { + } catch (e: CancellationException) { Logger.i { "MQTT Client loop cancelled" } throw e } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt new file mode 100644 index 000000000..045d3b7ec --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.transport + +import co.touchlab.kermit.Logger +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Shared heartbeat sender for Meshtastic radio transports. + * + * Constructs and sends a `ToRadio(heartbeat = Heartbeat(nonce = ...))` message to keep the firmware's idle timer from + * expiring. Each call uses a monotonically increasing nonce to prevent the firmware's per-connection duplicate-write + * filter from silently dropping it. + * + * @param sendToRadio callback to transmit the encoded heartbeat bytes to the radio + * @param afterHeartbeat optional suspend callback invoked after sending (e.g. to schedule a drain) + * @param logTag tag for log messages + */ +class HeartbeatSender( + private val sendToRadio: (ByteArray) -> Unit, + private val afterHeartbeat: (suspend () -> Unit)? = null, + private val logTag: String = "HeartbeatSender", +) { + @OptIn(ExperimentalAtomicApi::class) + private val nonce = AtomicInt(0) + + /** + * Sends a heartbeat to the radio. + * + * The firmware responds to heartbeats by queuing a `queueStatus` FromRadio packet, proving the link is alive and + * keeping the local node's lastHeard timestamp current. + */ + @OptIn(ExperimentalAtomicApi::class) + suspend fun sendHeartbeat() { + val n = nonce.fetchAndAdd(1) + Logger.v { "[$logTag] Sending ToRadio heartbeat (nonce=$n)" } + sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)).encode()) + afterHeartbeat?.invoke() + } +} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt similarity index 70% rename from core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt rename to core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt index d4a41ba95..f1049f897 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt @@ -36,10 +36,9 @@ import org.meshtastic.core.testing.FakeBluetoothRepository import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) -class BleRadioInterfaceTest { +class BleRadioTransportTest { private val testScope = TestScope() private val scanner = FakeBleScanner() @@ -56,66 +55,69 @@ class BleRadioInterfaceTest { } @Test - fun `connect attempts to scan and connect via init`() = runTest { + fun `connect attempts to scan and connect via start`() = runTest { val device = FakeBleDevice(address = address, name = "Test Device") scanner.emitDevice(device) - val bleInterface = - BleRadioInterface( - serviceScope = testScope, + val bleTransport = + BleRadioTransport( + scope = testScope, scanner = scanner, bluetoothRepository = bluetoothRepository, connectionFactory = connectionFactory, - service = service, + callback = service, address = address, ) + bleTransport.start() - // init starts connect() which is async + // start() begins connect() which is async // In a real test we'd verify the connection state, // but for now this confirms it works with the fakes. - assertEquals(address, bleInterface.address) + assertEquals(address, bleTransport.address) } @Test fun `address returns correct value`() { - val bleInterface = - BleRadioInterface( - serviceScope = testScope, + val bleTransport = + BleRadioTransport( + scope = testScope, scanner = scanner, bluetoothRepository = bluetoothRepository, connectionFactory = connectionFactory, - service = service, + callback = service, address = address, ) - assertEquals(address, bleInterface.address) + assertEquals(address, bleTransport.address) } /** - * After [RECONNECT_FAILURE_THRESHOLD] consecutive connection failures, [RadioInterfaceService.onDisconnect] must be - * called so the higher layers can react (e.g. start the device-sleep timeout in [MeshConnectionManagerImpl]). + * After [BleReconnectPolicy.DEFAULT_FAILURE_THRESHOLD] consecutive connection failures, + * [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep + * timeout in [MeshConnectionManagerImpl]). * - * Virtual-time breakdown (RECONNECT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses, + * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses, * connectAndAwait throws, backoff 5 s starts t = 6 000 ms — backoff ends t = 7 000 ms — iteration 2 settle delay * elapses, connectAndAwait throws, backoff 10 s starts t = 17 000 ms — backoff ends t = 18 000 ms — iteration 3 * settle delay elapses, connectAndAwait throws → onDisconnect called */ @Test - fun `onDisconnect is called after RECONNECT_FAILURE_THRESHOLD consecutive failures`() = runTest { + fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest { val device = FakeBleDevice(address = address, name = "Test Device") bluetoothRepository.bond(device) // skip BLE scan — device is already bonded // Make every connectAndAwait call throw so each iteration counts as one failure. connection.connectException = RadioNotConnectedException("simulated failure") - val bleInterface = - BleRadioInterface( - serviceScope = this, + val bleTransport = + BleRadioTransport( + scope = this, scanner = scanner, bluetoothRepository = bluetoothRepository, connectionFactory = connectionFactory, - service = service, + callback = service, address = address, ) + bleTransport.start() // Advance through exactly 3 failure iterations (≈18 001 ms virtual time). // The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended @@ -125,12 +127,12 @@ class BleRadioInterfaceTest { verify { service.onDisconnect(any(), any()) } // Cancel the reconnect loop so runTest can complete. - bleInterface.close() + bleTransport.close() } /** - * After [RECONNECT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and signal a permanent - * disconnect. This prevents infinite battery drain when the device is genuinely offline. + * After [BleReconnectPolicy.DEFAULT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and + * signal a permanent disconnect. This prevents infinite battery drain when the device is genuinely offline. * * Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw + * backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s @@ -138,22 +140,23 @@ class BleRadioInterfaceTest { * variance. */ @Test - fun `reconnect loop stops after RECONNECT_MAX_FAILURES with permanent disconnect`() = runTest { + fun `reconnect loop stops after DEFAULT_MAX_FAILURES with permanent disconnect`() = runTest { val device = FakeBleDevice(address = address, name = "Test Device") bluetoothRepository.bond(device) connection.connectException = RadioNotConnectedException("simulated failure") every { service.onDisconnect(any(), any()) } returns Unit - val bleInterface = - BleRadioInterface( - serviceScope = this, + val bleTransport = + BleRadioTransport( + scope = this, scanner = scanner, bluetoothRepository = bluetoothRepository, connectionFactory = connectionFactory, - service = service, + callback = service, address = address, ) + bleTransport.start() // Advance enough time for all 10 failures to occur. advanceTimeBy(400_001L) @@ -161,18 +164,6 @@ class BleRadioInterfaceTest { // Should have been called with isPermanent=true at least once (the final call). verify { service.onDisconnect(isPermanent = true, errorMessage = any()) } - bleInterface.close() - } - - @Test - fun `computeReconnectBackoff returns correct backoff values`() { - assertEquals(5.seconds, computeReconnectBackoff(0)) - assertEquals(5.seconds, computeReconnectBackoff(1)) - assertEquals(10.seconds, computeReconnectBackoff(2)) - assertEquals(20.seconds, computeReconnectBackoff(3)) - assertEquals(40.seconds, computeReconnectBackoff(4)) - assertEquals(60.seconds, computeReconnectBackoff(5)) - assertEquals(60.seconds, computeReconnectBackoff(10)) - assertEquals(60.seconds, computeReconnectBackoff(100)) + bleTransport.close() } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt new file mode 100644 index 000000000..a6a7aa82c --- /dev/null +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class BleReconnectPolicyTest { + + @Test + fun `stable disconnect resets failures and returns Continue`() { + val policy = BleReconnectPolicy() + // Simulate one prior failure + policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) + assertEquals(1, policy.consecutiveFailures) + + // Now a stable disconnect should reset + val action = + policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false)) + assertEquals(BleReconnectPolicy.Action.Continue, action) + assertEquals(0, policy.consecutiveFailures) + } + + @Test + fun `intentional disconnect resets failures and returns Continue`() { + val policy = BleReconnectPolicy() + policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) + + val action = + policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = true)) + assertEquals(BleReconnectPolicy.Action.Continue, action) + assertEquals(0, policy.consecutiveFailures) + } + + @Test + fun `unstable disconnect increments failures`() { + val policy = BleReconnectPolicy() + val action = + policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = false)) + assertEquals(1, policy.consecutiveFailures) + assertTrue(action is BleReconnectPolicy.Action.Retry) + } + + @Test + fun `failure at threshold signals transient disconnect`() { + val policy = BleReconnectPolicy(failureThreshold = 3) + // Accumulate failures up to threshold + repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } + val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) + assertEquals(3, policy.consecutiveFailures) + assertTrue(action is BleReconnectPolicy.Action.SignalTransient) + } + + @Test + fun `failure at max gives up permanently`() { + val policy = BleReconnectPolicy(maxFailures = 3) + repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } + val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) + assertEquals(BleReconnectPolicy.Action.GiveUp, action) + } + + @Test + fun `backoff increases with consecutive failures`() { + val policy = BleReconnectPolicy() + val backoffs = + (1..5).map { i -> + val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) + when (action) { + is BleReconnectPolicy.Action.Retry -> action.backoff + is BleReconnectPolicy.Action.SignalTransient -> action.backoff + else -> error("Unexpected action: $action") + } + } + // Verify backoffs are non-decreasing + for (i in 0 until backoffs.size - 1) { + assertTrue(backoffs[i] <= backoffs[i + 1], "Expected ${backoffs[i]} <= ${backoffs[i + 1]}") + } + } + + @Test + fun `custom backoff strategy is used`() { + val customBackoff = 42.seconds + val policy = BleReconnectPolicy(backoffStrategy = { customBackoff }) + val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) + assertTrue(action is BleReconnectPolicy.Action.Retry) + assertEquals(customBackoff, action.backoff) + } + + @Test + fun `maxFailures equal to failureThreshold gives up without signalling transient`() { + val policy = BleReconnectPolicy(maxFailures = 3, failureThreshold = 3) + repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } + val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) + // GiveUp takes priority over SignalTransient when both thresholds are the same + assertEquals(BleReconnectPolicy.Action.GiveUp, action) + } + + @Test + fun `failure count resets after stable disconnect then re-increments`() { + val policy = BleReconnectPolicy() + // Accumulate two failures + repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } + assertEquals(2, policy.consecutiveFailures) + + // Stable disconnect resets + policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false)) + assertEquals(0, policy.consecutiveFailures) + + // New failure starts from 1 + policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) + assertEquals(1, policy.consecutiveFailures) + } + + // region execute() loop tests + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `execute gives up after maxFailures and calls onPermanentDisconnect`() = runTest { + val policy = + BleReconnectPolicy(maxFailures = 3, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) + var permanentError: Throwable? = null + var permanentCalled = false + var transientCalled = false + + policy.execute( + attempt = { BleReconnectPolicy.Outcome.Failed(RuntimeException("connection failed")) }, + onTransientDisconnect = { transientCalled = true }, + onPermanentDisconnect = { error -> + permanentCalled = true + permanentError = error + }, + ) + + assertTrue(permanentCalled, "onPermanentDisconnect should have been called") + assertNotNull(permanentError, "error should be passed to onPermanentDisconnect") + assertEquals("connection failed", permanentError?.message) + assertEquals(3, policy.consecutiveFailures) + // failureThreshold defaults to 3, same as maxFailures here, so GiveUp takes priority + assertTrue(!transientCalled, "onTransientDisconnect should not be called when GiveUp fires first") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `execute calls onTransientDisconnect at threshold then continues retrying`() = runTest { + var attemptCount = 0 + val policy = + BleReconnectPolicy( + maxFailures = 5, + failureThreshold = 2, + settleDelay = 1.milliseconds, + backoffStrategy = { 1.milliseconds }, + ) + var transientCount = 0 + + policy.execute( + attempt = { + attemptCount++ + BleReconnectPolicy.Outcome.Failed(RuntimeException("fail #$attemptCount")) + }, + onTransientDisconnect = { transientCount++ }, + onPermanentDisconnect = {}, + ) + + assertEquals(5, attemptCount, "should attempt exactly maxFailures times") + // Transient is signalled for failures 2, 3, 4 (at or above threshold, below maxFailures) + assertEquals(3, transientCount, "should signal transient for each failure at or above threshold") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `execute continues immediately after stable disconnect`() = runTest { + var attemptCount = 0 + val policy = + BleReconnectPolicy(maxFailures = 5, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) + + policy.execute( + attempt = { + attemptCount++ + if (attemptCount <= 2) { + // First two attempts connect briefly and disconnect stably + BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false) + } else { + // Then fail until maxFailures + BleReconnectPolicy.Outcome.Failed(RuntimeException("fail")) + } + }, + onTransientDisconnect = {}, + onPermanentDisconnect = {}, + ) + + // 2 stable disconnects + 5 failures (counter resets after each stable, so needs 5 more to hit max) + assertEquals(7, attemptCount) + assertEquals(5, policy.consecutiveFailures) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `execute passes null error for unstable disconnect at threshold`() = runTest { + val policy = + BleReconnectPolicy( + maxFailures = 5, + failureThreshold = 2, + settleDelay = 1.milliseconds, + backoffStrategy = { 1.milliseconds }, + ) + val transientErrors = mutableListOf() + var attemptCount = 0 + + policy.execute( + attempt = { + attemptCount++ + // Use unstable disconnects (not Failed) so lastError is null + BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = false) + }, + onTransientDisconnect = { error -> transientErrors.add(error) }, + onPermanentDisconnect = {}, + ) + + // Disconnected outcomes don't have errors, so all transient callbacks get null + assertTrue(transientErrors.all { it == null }, "Disconnected outcomes should pass null error") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `execute stops when coroutine is cancelled`() = runTest { + var attemptCount = 0 + val policy = + BleReconnectPolicy(maxFailures = 100, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) + + val job = + backgroundScope.launch { + policy.execute( + attempt = { + attemptCount++ + // Always succeed stably — loop should run until cancelled + BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false) + }, + onTransientDisconnect = {}, + onPermanentDisconnect = {}, + ) + } + + // Let a few iterations run, then cancel + advanceTimeBy(50) + job.cancel() + advanceUntilIdle() + + // Should have made some attempts but not reached maxFailures + assertTrue(attemptCount > 0, "should have attempted at least once") + assertTrue(attemptCount < 100, "should not have exhausted all failures — was cancelled") + } + + // endregion +} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt index c4e64d36a..f3514c752 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt @@ -22,7 +22,7 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds /** - * Tests the exponential backoff schedule used by [BleRadioInterface] when consecutive connection attempts fail. The + * Tests the exponential backoff schedule used by [BleRadioTransport] when consecutive connection attempts fail. The * schedule is: failure #1 → 5 s failure #2 → 10 s failure #3 → 20 s failure #4 → 40 s failure #5+ → 60 s (capped) */ class ReconnectBackoffTest { diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt similarity index 75% rename from core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt rename to core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt index 4c4e9b4be..6faa69217 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt @@ -17,8 +17,6 @@ package org.meshtastic.core.network.radio import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.every import dev.mokkery.mock import dev.mokkery.verify import io.kotest.property.Arb @@ -29,17 +27,16 @@ import io.kotest.property.checkAll import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.meshtastic.core.network.transport.StreamFrameCodec -import org.meshtastic.core.repository.RadioInterfaceService -import kotlin.test.BeforeTest +import org.meshtastic.core.repository.RadioTransportCallback import kotlin.test.Test import kotlin.test.assertTrue -class StreamInterfaceTest { +class StreamTransportTest { - private val radioService: RadioInterfaceService = mock(MockMode.autofill) - private lateinit var fakeStream: FakeStreamInterface + private val callback: RadioTransportCallback = mock(MockMode.autofill) + private lateinit var fakeStream: FakeStreamTransport - class FakeStreamInterface(service: RadioInterfaceService) : StreamInterface(service) { + class FakeStreamTransport(callback: RadioTransportCallback, scope: TestScope) : StreamTransport(callback, scope) { val sentBytes = mutableListOf() override fun sendBytes(p: ByteArray) { @@ -59,21 +56,18 @@ class StreamInterfaceTest { public override fun connect() = super.connect() } - @BeforeTest - fun setUp() { - every { radioService.serviceScope } returns TestScope() - } + private val testScope = TestScope() @Test fun `handleSendToRadio property test`() = runTest { - fakeStream = FakeStreamInterface(radioService) + fakeStream = FakeStreamTransport(callback, testScope) checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) } } @Test fun `readChar property test`() = runTest { - fakeStream = FakeStreamInterface(radioService) + fakeStream = FakeStreamTransport(callback, testScope) checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data -> data.forEach { fakeStream.feed(it) } @@ -83,11 +77,11 @@ class StreamInterfaceTest { @Test fun `connect sends wake bytes`() { - fakeStream = FakeStreamInterface(radioService) + fakeStream = FakeStreamTransport(callback, testScope) fakeStream.connect() assertTrue(fakeStream.sentBytes.isNotEmpty()) assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES)) - verify { radioService.onConnect() } + verify { callback.onConnect() } } } diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt similarity index 57% rename from core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt rename to core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt index 0ffb731cf..7b1106dc4 100644 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt @@ -17,76 +17,76 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.network.transport.StreamFrameCodec import org.meshtastic.core.network.transport.TcpTransport -import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport +import org.meshtastic.core.repository.RadioTransportCallback +import kotlin.concurrent.Volatile /** - * Android TCP radio interface — thin adapter over the shared [TcpTransport] from `core:network`. + * TCP radio transport — thin adapter over the shared [TcpTransport] from `core:network`. * - * Manages the mapping between the Android-specific [StreamInterface]/[RadioTransport] contract and the shared transport - * layer. + * Implements [RadioTransport] directly via composition over [TcpTransport], delegating send/receive to the transport + * and calling [RadioTransportCallback] for lifecycle events. This avoids the previous inheritance from + * [StreamTransport] which created a dead [StreamFrameCodec] and required overriding `sendBytes` as a no-op. */ -open class TCPInterface( - service: RadioInterfaceService, +open class TcpRadioTransport( + private val callback: RadioTransportCallback, + private val scope: CoroutineScope, private val dispatchers: CoroutineDispatchers, private val address: String, -) : StreamInterface(service) { +) : RadioTransport { companion object { const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT } + /** Guards against a double [RadioTransportCallback.onDisconnect] when [close] triggers [TcpTransport.stop]. */ + @Volatile private var closing = false + private val transport = TcpTransport( dispatchers = dispatchers, - scope = service.serviceScope, + scope = scope, listener = object : TcpTransport.Listener { override fun onConnected() { - super@TCPInterface.connect() + callback.onConnect() } override fun onDisconnected() { - // Transport already performed teardown; only propagate lifecycle to StreamInterface. + if (closing) return // close() will fire the permanent disconnect itself // TCP disconnects are transient (not permanent) — the transport will auto-reconnect. - super@TCPInterface.onDeviceDisconnect(false, isPermanent = false) + callback.onDisconnect(isPermanent = false) } override fun onPacketReceived(bytes: ByteArray) { - service.handleFromRadio(bytes) + callback.handleFromRadio(bytes) } }, - logTag = "TCPInterface[$address]", + logTag = "TcpRadioTransport[$address]", ) - init { - connect() - } - - override fun sendBytes(p: ByteArray) { - // Direct byte sending is handled by the transport; this is used by StreamInterface for serial compat - Logger.d { "[$address] TCPInterface.sendBytes delegated to transport" } - } - - override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) { - transport.stop() - super.onDeviceDisconnect(waitForStopped, isPermanent = false) - } - - override fun connect() { + override fun start() { transport.start(address) } + override fun close() { + Logger.d { "[$address] Closing TCP transport" } + closing = true + transport.stop() + callback.onDisconnect(isPermanent = true) + } + override fun keepAlive() { Logger.d { "[$address] TCP keepAlive" } - service.serviceScope.handledLaunch { transport.sendHeartbeat() } + scope.handledLaunch { transport.sendHeartbeat() } } override fun handleSendToRadio(p: ByteArray) { - service.serviceScope.handledLaunch { transport.sendPacket(p) } + scope.handledLaunch { transport.sendPacket(p) } } } diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt index 264e42f89..172423470 100644 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.withContext import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.proto.Heartbeat import org.meshtastic.proto.ToRadio import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -34,13 +33,14 @@ import java.net.InetAddress import java.net.Socket import java.net.SocketTimeoutException import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger /** * Shared JVM TCP transport for Meshtastic radios. * * Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff) and uses [StreamFrameCodec] for the - * START1/START2 stream framing protocol. Heartbeat scheduling is owned by [SharedRadioInterfaceService]; this class - * only exposes [sendHeartbeat] for external callers. + * START1/START2 stream framing protocol. [sendHeartbeat] sends a heartbeat with a monotonically-increasing nonce so the + * firmware's per-connection duplicate-write filter does not silently drop it. * * Used by Android and Desktop via the shared `SharedRadioInterfaceService`. */ @@ -109,6 +109,8 @@ class TcpTransport( @Volatile private var timeoutEvents: Int = 0 + private val heartbeatNonce = AtomicInteger(0) + /** Whether the transport is currently connected. */ val isConnected: Boolean get() { @@ -146,9 +148,10 @@ class TcpTransport( bytesSent += payload.size } - /** Send a heartbeat packet to keep the connection alive. */ + /** Send a heartbeat packet with a monotonically-increasing nonce to keep the connection alive. */ suspend fun sendHeartbeat() { - val heartbeat = ToRadio(heartbeat = Heartbeat()) + val nonce = heartbeatNonce.getAndIncrement() + val heartbeat = ToRadio(heartbeat = org.meshtastic.proto.Heartbeat(nonce = nonce)) sendPacket(heartbeat.encode()) } diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt index a77331267..d43063d52 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt @@ -19,18 +19,19 @@ package org.meshtastic.core.network import co.touchlab.kermit.Logger import com.fazecast.jSerialComm.SerialPort import com.fazecast.jSerialComm.SerialPortTimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.network.radio.StreamInterface -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.proto.Heartbeat -import org.meshtastic.proto.ToRadio +import org.meshtastic.core.network.radio.StreamTransport +import org.meshtastic.core.network.transport.HeartbeatSender +import org.meshtastic.core.repository.RadioTransportCallback import java.io.File /** - * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamInterface] for START1/START2 packet + * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamTransport] for START1/START2 packet * framing. * * Use the [open] factory method instead of the constructor directly to ensure the serial port is opened and the read @@ -40,12 +41,15 @@ class SerialTransport private constructor( private val portName: String, private val baudRate: Int = DEFAULT_BAUD_RATE, - service: RadioInterfaceService, + callback: RadioTransportCallback, + scope: CoroutineScope, private val dispatchers: CoroutineDispatchers, -) : StreamInterface(service) { +) : StreamTransport(callback, scope) { private var serialPort: SerialPort? = null private var readJob: Job? = null + private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$portName]") + /** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */ private fun startConnection(): Boolean { return try { @@ -57,7 +61,7 @@ private constructor( port.setDTR() port.setRTS() Logger.i { "[$portName] Serial port opened (baud=$baudRate)" } - super.connect() // Sends WAKE_BYTES and signals service.onConnect() + super.connect() // Sends WAKE_BYTES and signals callback.onConnect() startReadLoop(port) true } else { @@ -74,7 +78,7 @@ private constructor( private fun startReadLoop(port: SerialPort) { Logger.d { "[$portName] Starting serial read loop" } readJob = - service.serviceScope.launch(dispatchers.io) { + scope.launch(dispatchers.io) { val input = port.inputStream val buffer = ByteArray(READ_BUFFER_SIZE) try { @@ -91,7 +95,7 @@ private constructor( } } catch (_: SerialPortTimeoutException) { // Expected timeout when no data is available - } catch (e: kotlinx.coroutines.CancellationException) { + } catch (e: CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { if (isActive) { @@ -102,7 +106,7 @@ private constructor( reading = false } } - } catch (e: kotlinx.coroutines.CancellationException) { + } catch (e: CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { if (isActive) { @@ -140,11 +144,9 @@ private constructor( } override fun keepAlive() { - // Send a ToRadio heartbeat so the firmware resets its idle timer and responds with - // a FromRadio queueStatus — proving the serial link is alive. Without this, the - // serial transport has no way to detect a silently dead device. - Logger.d { "[$portName] Serial keepAlive — sending heartbeat" } - handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode()) + // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the + // serial link is alive. + scope.launch { heartbeatSender.sendHeartbeat() } } private fun closePortResources() { @@ -168,19 +170,20 @@ private constructor( /** * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent - * disconnect to the [service] and returns the (non-connected) instance. + * disconnect to the [callback] and returns the (non-connected) instance. */ fun open( portName: String, baudRate: Int = DEFAULT_BAUD_RATE, - service: RadioInterfaceService, + callback: RadioTransportCallback, + scope: CoroutineScope, dispatchers: CoroutineDispatchers, ): SerialTransport { - val transport = SerialTransport(portName, baudRate, service, dispatchers) + val transport = SerialTransport(portName, baudRate, callback, scope, dispatchers) if (!transport.startConnection()) { val errorMessage = diagnoseOpenFailure(portName) Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" } - service.onDisconnect(isPermanent = true, errorMessage = errorMessage) + callback.onDisconnect(isPermanent = true, errorMessage = errorMessage) } return transport } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt index 30aade866..a68157943 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.repository +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry @@ -28,7 +29,7 @@ interface MeshServiceNotifications { fun initChannels() - fun updateServiceStateNotification(state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?) + fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) suspend fun updateMessageNotification( contactKey: String, diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index bb9cea52d..8dcc21c71 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -39,7 +39,7 @@ import org.meshtastic.core.model.MeshActivity * * @see ServiceRepository.connectionState */ -interface RadioInterfaceService { +interface RadioInterfaceService : RadioTransportCallback { /** The device types supported by this platform's radio interface. */ val supportedDeviceTypes: List @@ -65,8 +65,8 @@ interface RadioInterfaceService { /** Flow of the current device address. */ val currentDeviceAddressFlow: StateFlow - /** Whether we are currently using a mock interface. */ - fun isMockInterface(): Boolean + /** Whether we are currently using a mock transport. */ + fun isMockTransport(): Boolean /** Flow of raw data received from the radio. */ val receivedData: SharedFlow @@ -89,15 +89,6 @@ interface RadioInterfaceService { /** Constructs a full radio address for the specific interface type. */ fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String - /** Called by an interface when it has successfully connected. */ - fun onConnect() - - /** Called by an interface when it has disconnected. */ - fun onDisconnect(isPermanent: Boolean, errorMessage: String? = null) - - /** Called by an interface when it has received raw data from the radio. */ - fun handleFromRadio(bytes: ByteArray) - /** Flow of user-facing connection error messages (e.g. permission failures). */ val connectionError: SharedFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt index 41015381f..c6132a103 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt @@ -26,6 +26,14 @@ interface RadioTransport : Closeable { /** Sends a raw byte array to the radio hardware. */ fun handleSendToRadio(p: ByteArray) + /** + * Initializes the transport after construction. Called by the factory once the transport has been fully created. + * + * This separates construction from side effects (connecting, launching coroutines), making transports easier to + * test and reason about. + */ + fun start() {} + /** * If we think we are connected, but we don't hear anything from the device, we might be in a zombie state. This * function can be implemented by transports to see if we are really connected. diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt new file mode 100644 index 000000000..9771062a5 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +/** + * Narrow callback interface for transport → service communication. + * + * Transport implementations ([RadioTransport]) need only these three methods to report lifecycle events and deliver + * data. This replaces the previous pattern of passing the full [RadioInterfaceService] to transport constructors, + * decoupling transports from the service layer. + */ +interface RadioTransportCallback { + /** Called when the transport has successfully established a connection. */ + fun onConnect() + + /** + * Called when the transport has disconnected. + * + * @param isPermanent true if the device is definitely gone (e.g. USB unplugged, max retries exhausted), false if it + * may come back (e.g. BLE range, TCP transient). + * @param errorMessage optional user-facing error message describing the disconnect reason. + */ + fun onDisconnect(isPermanent: Boolean, errorMessage: String? = null) + + /** Called when the transport has received raw data from the radio. */ + fun handleFromRadio(bytes: ByteArray) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt index 918657e99..c3d2abff1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt @@ -28,8 +28,8 @@ interface RadioTransportFactory { /** The device types supported by this factory. */ val supportedDeviceTypes: List - /** Whether we are currently forced into using a mock interface (e.g., Firebase Test Lab). */ - fun isMockInterface(): Boolean + /** Whether we are currently forced into using a mock transport (e.g., Firebase Test Lab). */ + fun isMockTransport(): Boolean /** Creates a transport for the given [address], or a NOP implementation if invalid/unsupported. */ fun createTransport(address: String, service: RadioInterfaceService): RadioTransport diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index a96b3ffc1..af7cb85c2 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -17,15 +17,22 @@ package org.meshtastic.core.service import android.content.Context +import android.content.Intent import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.StateFlow import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User /** * Android [RadioController] implementation that delegates to the bound [MeshService] via AIDL. @@ -69,41 +76,37 @@ class AndroidRadioControllerImpl( override suspend fun sendSharedContact(nodeNum: Int): Boolean { val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) val contact = - org.meshtastic.proto.SharedContact( - node_num = nodeDef.num, - user = nodeDef.user, - manually_verified = nodeDef.manuallyVerified, - ) + SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified) val action = ServiceAction.SendContact(contact) serviceRepository.onServiceAction(action) return action.result.await() } - override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) { + override suspend fun setLocalConfig(config: Config) { serviceRepository.meshService?.setConfig(config.encode()) } - override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) { + override suspend fun setLocalChannel(channel: Channel) { serviceRepository.meshService?.setChannel(channel.encode()) } - override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) { + override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode()) } - override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) { + override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode()) } - override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) { + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode()) } - override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) { + override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode()) } - override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) { + override suspend fun setFixedPosition(destNum: Int, position: Position) { serviceRepository.meshService?.setFixedPosition(destNum, position) } @@ -171,7 +174,7 @@ class AndroidRadioControllerImpl( serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) } - override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) { + override suspend fun requestPosition(destNum: Int, currentPosition: Position) { serviceRepository.meshService?.requestPosition(destNum, currentPosition) } @@ -214,10 +217,7 @@ class AndroidRadioControllerImpl( @Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder serviceRepository.meshService?.setDeviceAddress(address) // Ensure service is running/restarted to handle the new address - val intent = - android.content.Intent().apply { - setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") - } + val intent = Intent().apply { setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") } context.startForegroundService(intent) } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 05f1135f1..028030f76 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -50,6 +50,12 @@ import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.PortNum +/** + * Android foreground service that hosts the Meshtastic mesh radio connection. + * + * Acts as the lifecycle anchor for the [MeshServiceOrchestrator], which manages all manager initialization and + * connection state. Exposes an AIDL binder for external client integration via [core:api]. + */ // IMeshService is deprecated but still required for AIDL binding @Suppress("TooManyFunctions", "LargeClass", "DEPRECATION") class MeshService : Service() { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index 75bbe27ce..cff4ec041 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -41,6 +41,7 @@ import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node @@ -303,17 +304,14 @@ class MeshServiceNotificationsImpl( // region Public Notification Methods @Suppress("CyclomaticComplexMethod", "NestedBlockDepth") - override fun updateServiceStateNotification( - state: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ) { + override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) { val summaryString = when (state) { - is org.meshtastic.core.model.ConnectionState.Connected -> + is ConnectionState.Connected -> getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected) - is org.meshtastic.core.model.ConnectionState.Disconnected -> getString(Res.string.disconnected) - is org.meshtastic.core.model.ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) - is org.meshtastic.core.model.ConnectionState.Connecting -> getString(Res.string.connecting) + is ConnectionState.Disconnected -> getString(Res.string.disconnected) + is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) + is ConnectionState.Connecting -> getString(Res.string.connecting) } // Update caches if telemetry is provided diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 1865dd4c6..df860a4a2 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -19,9 +19,12 @@ package org.meshtastic.core.service import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -96,10 +99,7 @@ class SharedRadioInterfaceService( override val receivedData: SharedFlow = _receivedData private val _meshActivity = - MutableSharedFlow( - extraBufferCapacity = 64, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, - ) + MutableSharedFlow(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val meshActivity: SharedFlow = _meshActivity.asSharedFlow() private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64) @@ -109,12 +109,12 @@ class SharedRadioInterfaceService( get() = _serviceScope private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - private var radioIf: RadioTransport? = null - private var runningInterfaceId: InterfaceId? = null + private var radioTransport: RadioTransport? = null + private var runningTransportId: InterfaceId? = null private var isStarted = false - private val listenersInitialized = kotlinx.atomicfu.atomic(false) - private var heartbeatJob: kotlinx.coroutines.Job? = null + private val listenersInitialized = atomic(false) + private var heartbeatJob: Job? = null private var lastHeartbeatMillis = 0L @Volatile private var lastDataReceivedMillis = 0L @@ -130,7 +130,7 @@ class SharedRadioInterfaceService( } private val initLock = Mutex() - private val interfaceMutex = Mutex() + private val transportMutex = Mutex() private fun initStateListeners() { if (listenersInitialized.value) return @@ -141,10 +141,10 @@ class SharedRadioInterfaceService( radioPrefs.devAddr .onEach { addr -> - interfaceMutex.withLock { + transportMutex.withLock { if (_currentDeviceAddressFlow.value != addr) { _currentDeviceAddressFlow.value = addr - startInterfaceLocked() + startTransportLocked() } } } @@ -152,11 +152,11 @@ class SharedRadioInterfaceService( bluetoothRepository.state .onEach { state -> - interfaceMutex.withLock { + transportMutex.withLock { if (state.enabled) { - startInterfaceLocked() - } else if (runningInterfaceId == InterfaceId.BLUETOOTH) { - stopInterfaceLocked() + startTransportLocked() + } else if (runningTransportId == InterfaceId.BLUETOOTH) { + stopTransportLocked() } } } @@ -165,11 +165,11 @@ class SharedRadioInterfaceService( networkRepository.networkAvailable .onEach { state -> - interfaceMutex.withLock { + transportMutex.withLock { if (state) { - startInterfaceLocked() - } else if (runningInterfaceId == InterfaceId.TCP) { - stopInterfaceLocked() + startTransportLocked() + } else if (runningTransportId == InterfaceId.TCP) { + stopTransportLocked() } } } @@ -180,11 +180,11 @@ class SharedRadioInterfaceService( } override fun connect() { - processLifecycle.coroutineScope.launch { interfaceMutex.withLock { startInterfaceLocked() } } + processLifecycle.coroutineScope.launch { transportMutex.withLock { startTransportLocked() } } initStateListeners() } - override fun isMockInterface(): Boolean = transportFactory.isMockInterface() + override fun isMockTransport(): Boolean = transportFactory.isMockTransport() override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = transportFactory.toInterfaceAddress(interfaceId, rest) @@ -215,17 +215,17 @@ class SharedRadioInterfaceService( _currentDeviceAddressFlow.value = sanitized processLifecycle.coroutineScope.launch { - interfaceMutex.withLock { - ignoreException { stopInterfaceLocked() } - startInterfaceLocked() + transportMutex.withLock { + ignoreException { stopTransportLocked() } + startTransportLocked() } } return true } - /** Must be called under [interfaceMutex]. */ - private fun startInterfaceLocked() { - if (radioIf != null) return + /** Must be called under [transportMutex]. */ + private fun startTransportLocked() { + if (radioTransport != null) return // Never autoconnect to the simulated node. The mock transport may be offered in the // device-picker UI on debug builds, but it must only connect when the user explicitly @@ -237,26 +237,26 @@ class SharedRadioInterfaceService( return } - Logger.i { "Starting radio interface for ${address.anonymize}" } + Logger.i { "Starting radio transport for ${address.anonymize}" } isStarted = true - runningInterfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } - radioIf = transportFactory.createTransport(address, this) + runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } + radioTransport = transportFactory.createTransport(address, this) startHeartbeat() } - /** Must be called under [interfaceMutex]. */ - private fun stopInterfaceLocked() { - val currentIf = radioIf - Logger.i { "Stopping interface $currentIf" } + /** Must be called under [transportMutex]. */ + private fun stopTransportLocked() { + val currentTransport = radioTransport + Logger.i { "Stopping transport $currentTransport" } isStarted = false - radioIf = null - runningInterfaceId = null - currentIf?.close() + radioTransport = null + runningTransportId = null + currentTransport?.close() - _serviceScope.cancel("stopping interface") + _serviceScope.cancel("stopping transport") _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - if (currentIf != null) { + if (currentTransport != null) { onDisconnect(isPermanent = true) } } @@ -295,23 +295,25 @@ class SharedRadioInterfaceService( fun keepAlive(now: Long = nowMillis) { if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) { - radioIf?.keepAlive() + radioTransport?.keepAlive() lastHeartbeatMillis = now } } override fun sendToRadio(bytes: ByteArray) { - // Capture radioIf reference atomically to avoid racing with stopInterfaceLocked() - // which sets radioIf = null and cancels _serviceScope. Without this snapshot, - // we could read a non-null radioIf but launch into an already-cancelled scope. - val currentIf = - radioIf + // Snapshot the transport to avoid calling handleSendToRadio on a null reference. + // There is still a benign race: stopTransportLocked() may cancel _serviceScope + // between the null-check and the launch, causing the coroutine to be silently + // dropped. This is acceptable — if the transport is shutting down, dropping the + // send is the correct behavior. + val currentTransport = + radioTransport ?: run { - Logger.w { "sendToRadio: no active radio interface, dropping ${bytes.size} bytes" } + Logger.w { "sendToRadio: no active radio transport, dropping ${bytes.size} bytes" } return } _serviceScope.handledLaunch { - currentIf.handleSendToRadio(bytes) + currentTransport.handleSendToRadio(bytes) _meshActivity.tryEmit(MeshActivity.Send) } } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt index dc36b9956..4f0a4b153 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.testing +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.proto.ClientNotification @@ -28,10 +29,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun initChannels() {} - override fun updateServiceStateNotification( - state: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ) {} + override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {} override suspend fun updateMessageNotification( contactKey: String, diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index fac69e28c..d23a7f1ec 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -19,8 +19,13 @@ package org.meshtastic.core.testing import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController +import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.User /** * A test double for [RadioController] that provides a no-op implementation and tracks calls for assertions in tests. @@ -79,19 +84,19 @@ class FakeRadioController : return true } - override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {} + override suspend fun setLocalConfig(config: Config) {} - override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {} + override suspend fun setLocalChannel(channel: Channel) {} - override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {} + override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {} - override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {} + override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {} - override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {} + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {} - override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {} + override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {} - override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {} + override suspend fun setFixedPosition(destNum: Int, position: Position) {} override suspend fun setRingtone(destNum: Int, ringtone: String) {} @@ -125,7 +130,7 @@ class FakeRadioController : override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {} - override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {} + override suspend fun requestPosition(destNum: Int, currentPosition: Position) {} override suspend fun requestUserInfo(destNum: Int) {} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt index 3b8c83fe9..9f11a2bc6 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt @@ -60,7 +60,7 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main val sentToRadio = mutableListOf() var connectCalled = false - override fun isMockInterface(): Boolean = true + override fun isMockTransport(): Boolean = true override fun sendToRadio(bytes: ByteArray) { sentToRadio.add(bytes) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 1e2021304..b1c4cebf2 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation3.runtime.NavKey import co.touchlab.kermit.Logger import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow @@ -34,6 +35,7 @@ import kotlinx.coroutines.flow.onEach import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.model.MeshActivity @@ -43,6 +45,7 @@ import org.meshtastic.core.model.TracerouteMapAvailability import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.model.util.dispatchMeshtasticUri +import org.meshtastic.core.navigation.DeepLinkRouter import org.meshtastic.core.repository.FirmwareReleaseRepository import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository @@ -84,7 +87,7 @@ class UIViewModel( val snackbarManager: SnackbarManager, ) : ViewModel() { - private val _navigationDeepLink = MutableSharedFlow>(replay = 1) + private val _navigationDeepLink = MutableSharedFlow>(replay = 1) val navigationDeepLink = _navigationDeepLink.asSharedFlow() /** @@ -97,10 +100,10 @@ class UIViewModel( * [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations. */ fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) { - val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString) + val commonUri = CommonUri.parse(uri.uriString) // Try navigation routing first - val navKeys = org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri) + val navKeys = DeepLinkRouter.route(commonUri) if (navKeys != null) { _navigationDeepLink.tryEmit(navKeys) return @@ -236,7 +239,7 @@ class UIViewModel( _sharedContactRequested.value = contact } - /** Called immediately after activity observes requestChannelUrl */ + /** Clears the pending shared contact request. */ fun clearSharedContactRequested() { _sharedContactRequested.value = null } @@ -255,7 +258,7 @@ class UIViewModel( val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() } - /** Called immediately after activity observes requestChannelUrl */ + /** Clears the pending channel set import request. */ fun clearRequestChannelUrl() { _requestChannelSet.value = null } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 978be6b26..336f87b54 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -34,16 +34,25 @@ import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.RadioController import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.repository.MQTTRepository +import org.meshtastic.core.network.service.ApiService +import org.meshtastic.core.network.service.ApiServiceImpl import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.MessageQueue +import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioTransportFactory import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.service.DirectRadioControllerImpl +import org.meshtastic.core.service.ServiceRepositoryImpl +import org.meshtastic.desktop.DesktopBuildConfig +import org.meshtastic.desktop.DesktopNotificationManager +import org.meshtastic.desktop.notification.DesktopMeshServiceNotifications +import org.meshtastic.desktop.radio.DesktopMessageQueue import org.meshtastic.desktop.radio.DesktopRadioTransportFactory import org.meshtastic.desktop.stub.NoopAppWidgetUpdater import org.meshtastic.desktop.stub.NoopCompassHeadingProvider @@ -55,6 +64,9 @@ import org.meshtastic.desktop.stub.NoopMeshWorkerManager import org.meshtastic.desktop.stub.NoopPhoneLocationProvider import org.meshtastic.desktop.stub.NoopPlatformAnalytics import org.meshtastic.desktop.stub.NoopServiceBroadcasts +import org.meshtastic.feature.node.compass.CompassHeadingProvider +import org.meshtastic.feature.node.compass.MagneticFieldProvider +import org.meshtastic.feature.node.compass.PhoneLocationProvider import org.meshtastic.core.ble.di.module as coreBleModule import org.meshtastic.core.common.di.module as coreCommonModule import org.meshtastic.core.data.di.module as coreDataModule @@ -124,7 +136,7 @@ fun desktopModule() = module { */ @Suppress("LongMethod") private fun desktopPlatformStubsModule() = module { - single { org.meshtastic.core.service.ServiceRepositoryImpl() } + single { ServiceRepositoryImpl() } single { DesktopRadioTransportFactory( dispatchers = get(), @@ -134,7 +146,7 @@ private fun desktopPlatformStubsModule() = module { ) } single { - org.meshtastic.core.service.DirectRadioControllerImpl( + DirectRadioControllerImpl( serviceRepository = get(), nodeRepository = get(), commandSender = get(), @@ -144,37 +156,29 @@ private fun desktopPlatformStubsModule() = module { locationManager = get(), ) } - single { org.meshtastic.desktop.DesktopNotificationManager(prefs = get()) } - single { - get() - } - single { - org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get()) - } + single { DesktopNotificationManager(prefs = get()) } + single { get() } + single { DesktopMeshServiceNotifications(notificationManager = get()) } single { NoopPlatformAnalytics() } single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } single { NoopMeshWorkerManager() } - single { - org.meshtastic.desktop.radio.DesktopMessageQueue(packetRepository = get(), radioController = get()) - } + single { DesktopMessageQueue(packetRepository = get(), radioController = get()) } single { NoopMeshLocationManager() } single { NoopLocationRepository() } single { NoopMQTTRepository() } - single { NoopCompassHeadingProvider() } - single { NoopPhoneLocationProvider() } - single { NoopMagneticFieldProvider() } + single { NoopCompassHeadingProvider() } + single { NoopPhoneLocationProvider() } + single { NoopMagneticFieldProvider() } // Desktop uses the real ApiService implementation (no flavor stub needed) - single { - org.meshtastic.core.network.service.ApiServiceImpl(client = get()) - } + single { ApiServiceImpl(client = get()) } // Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android) single { HttpClient(Java) { install(ContentNegotiation) { json(get()) } - if (org.meshtastic.desktop.DesktopBuildConfig.IS_DEBUG) { + if (DesktopBuildConfig.IS_DEBUG) { install(Logging) { logger = KermitHttpLogger level = LogLevel.HEADERS diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt index 0518620c0..ffaa0553b 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt @@ -24,7 +24,7 @@ import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.network.SerialTransport import org.meshtastic.core.network.radio.BaseRadioTransportFactory -import org.meshtastic.core.network.radio.TCPInterface +import org.meshtastic.core.network.radio.TcpRadioTransport import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory @@ -45,16 +45,22 @@ class DesktopRadioTransportFactory( override val supportedDeviceTypes: List = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB) - override fun isMockInterface(): Boolean = false + override fun isMockTransport(): Boolean = false override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when { address.startsWith(InterfaceId.TCP.id) -> { - TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString())) + TcpRadioTransport( + callback = service, + scope = service.serviceScope, + dispatchers = dispatchers, + address = address.removePrefix(InterfaceId.TCP.id.toString()), + ) } address.startsWith(InterfaceId.SERIAL.id) -> { SerialTransport.open( portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), - service = service, + callback = service, + scope = service.serviceScope, dispatchers = dispatchers, ) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index 8d53990e2..220b21d05 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -20,6 +20,7 @@ package org.meshtastic.desktop.stub import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.MessageStatus @@ -37,14 +39,11 @@ import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.Location import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.MqttClientProxyMessage -import org.meshtastic.proto.Telemetry import org.meshtastic.proto.Position as ProtoPosition /** @@ -66,12 +65,12 @@ private fun logWarn(message: String) { // region Transport / Radio Stubs (Android BLE/USB — no commonMain impl) class NoopRadioInterfaceService : RadioInterfaceService { - override val supportedDeviceTypes: List = emptyList() + override val supportedDeviceTypes: List = emptyList() override val connectionState = MutableStateFlow(ConnectionState.Disconnected) override val currentDeviceAddressFlow = MutableStateFlow(null) - override fun isMockInterface(): Boolean = false + override fun isMockTransport(): Boolean = false override val receivedData = MutableSharedFlow() override val meshActivity = MutableSharedFlow() @@ -98,65 +97,13 @@ class NoopRadioInterfaceService : RadioInterfaceService { override fun handleFromRadio(bytes: ByteArray) {} @Suppress("InjectDispatcher") - override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Default) + override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) } // endregion // region Notification / Platform Stubs (Android-only) -@Suppress("TooManyFunctions") -class NoopMeshServiceNotifications : MeshServiceNotifications { - override fun clearNotifications() {} - - override fun initChannels() {} - - override fun updateServiceStateNotification( - state: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ) {} - - override suspend fun updateMessageNotification( - contactKey: String, - name: String, - message: String, - isBroadcast: Boolean, - channelName: String?, - isSilent: Boolean, - ) {} - - override suspend fun updateWaypointNotification( - contactKey: String, - name: String, - message: String, - waypointId: Int, - isSilent: Boolean, - ) {} - - override suspend fun updateReactionNotification( - contactKey: String, - name: String, - emoji: String, - isBroadcast: Boolean, - channelName: String?, - isSilent: Boolean, - ) {} - - override fun showAlertNotification(contactKey: String, name: String, alert: String) {} - - override fun showNewNodeSeenNotification(node: Node) {} - - override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {} - - override fun showClientNotification(clientNotification: ClientNotification) {} - - override fun cancelMessageNotification(contactKey: String) {} - - override fun cancelLowBatteryNotification(node: Node) {} - - override fun clearClientNotification(notification: ClientNotification) {} -} - class NoopPlatformAnalytics : PlatformAnalytics { override fun track(event: String, vararg properties: DataPair) {} diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md index 3d09d68f3..68ed44809 100644 --- a/docs/decisions/architecture-review-2026-03.md +++ b/docs/decisions/architecture-review-2026-03.md @@ -60,7 +60,7 @@ The core transport abstraction was previously locked in `app/repository/radio/` 1. Defined `RadioTransport` interface in `core:repository/commonMain` (replacing `IRadioInterface`) 2. Moved `StreamFrameCodec`-based framing to `core:network/commonMain` 3. Moved TCP transport to `core:network/jvmAndroidMain` -4. The remaining `app/repository/radio/` implementations (BLE, Serial, Mock) now implement `RadioTransport`. +4. BLE, Serial, and Mock transports now reside in `core:network` and implement `RadioTransport`. **Recommended next steps:** 1. Move BLE transport to `core:ble/androidMain` diff --git a/docs/kmp-status.md b/docs/kmp-status.md index 95e4b6945..fb9d74175 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -27,7 +27,7 @@ Modules that share JVM-specific code between Android and desktop now standardize | `core:database` | ✅ | ✅ | Room KMP | | `core:domain` | ✅ | ✅ | UseCases | | `core:prefs` | ✅ | ✅ | Preferences layer | -| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface` | +| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioTransport` | | `core:data` | ✅ | ✅ | Data orchestration | | `core:ble` | ✅ | ✅ | Kable multiplatform BLE abstractions in commonMain | | `core:nfc` | ✅ | ✅ | NFC contract in commonMain; hardware in androidMain | @@ -116,7 +116,7 @@ Based on the latest codebase investigation, the following steps are proposed to | **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. | | **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. | | Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants | -| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioInterface`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | +| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioTransport`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | ## Navigation Parity Note @@ -150,7 +150,7 @@ Extracted to shared `commonMain` (no longer app-only): Extracted to core KMP modules: - Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain` - USB/Serial radio connections → `core:network/androidMain` -- TCP radio connections, BLE radio connections (`BleRadioInterface`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations) +- TCP radio connections, BLE radio connections (`BleRadioTransport`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations) Remaining to be extracted from `:app` or unified in `commonMain`: - `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface. `MapViewProvider` interface simplified — track rendering and traceroute rendering extracted to dedicated provider contracts) diff --git a/docs/roadmap.md b/docs/roadmap.md index 91d051f9f..9c9445485 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -57,7 +57,7 @@ These items address structural gaps identified in the March 2026 architecture re | TCP | Desktop (JVM) | ✅ Done — shared `StreamFrameCodec` + `TcpTransport` in `core:network` | | Serial/USB | Desktop (JVM) | ✅ Done — jSerialComm | | MQTT | All (KMP) | ✅ Completed — KMQTT in commonMain | -| BLE | All (KMP) | ✅ Done — Kable in `commonMain` (`BleRadioInterface`) | +| BLE | All (KMP) | ✅ Done — Kable in `commonMain` (`BleRadioTransport`) | ### Desktop Feature Gaps diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index e4bb00c6b..d094aa170 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -54,8 +54,8 @@ open class ScannerViewModel( private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers, private val bleScanner: org.meshtastic.core.ble.BleScanner? = null, ) : ViewModel() { - private val _showMockInterface = MutableStateFlow(false) - val showMockInterface: StateFlow = _showMockInterface.asStateFlow() + private val _showMockTransport = MutableStateFlow(false) + val showMockTransport: StateFlow = _showMockTransport.asStateFlow() private val _errorText = MutableStateFlow(null) val errorText: StateFlow = _errorText.asStateFlow() @@ -68,7 +68,7 @@ open class ScannerViewModel( private var scanJob: kotlinx.coroutines.Job? = null init { - _showMockInterface.value = radioInterfaceService.isMockInterface() + _showMockTransport.value = radioInterfaceService.isMockTransport() } fun startBleScan() { @@ -77,25 +77,26 @@ open class ScannerViewModel( isBleScanningState.value = true scannedBleDevices.value = emptyMap() - scanJob = viewModelScope.launch { - try { - bleScanner - .scan( - timeout = kotlin.time.Duration.INFINITE, - serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID, - ) - .flowOn(dispatchers.io) - .collect { device -> - if (!scannedBleDevices.value.containsKey(device.address)) { - scannedBleDevices.update { current -> current + (device.address to device) } + scanJob = + viewModelScope.launch { + try { + bleScanner + .scan( + timeout = kotlin.time.Duration.INFINITE, + serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID, + ) + .flowOn(dispatchers.io) + .collect { device -> + if (!scannedBleDevices.value.containsKey(device.address)) { + scannedBleDevices.update { current -> current + (device.address to device) } + } } - } - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - co.touchlab.kermit.Logger.w(e) { "BLE scan failed" } - } finally { - isBleScanningState.value = false + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + co.touchlab.kermit.Logger.w(e) { "BLE scan failed" } + } finally { + isBleScanningState.value = false + } } - } } fun stopBleScan() { @@ -105,7 +106,7 @@ open class ScannerViewModel( } private val discoveredDevicesFlow = - showMockInterface + showMockTransport .flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 441b81c84..7fdc287cd 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -167,17 +167,19 @@ fun ConnectionsScreen( Spacer(modifier = Modifier.height(4.dp)) val uiState = when { - connectionState is ConnectionState.Connected && ourNode != null -> 2 + connectionState is ConnectionState.Connected && ourNode != null -> + ConnectionUiState.CONNECTED_WITH_NODE + connectionState is ConnectionState.Connected || connectionState == ConnectionState.Connecting || - selectedDevice != NO_DEVICE_SELECTED -> 1 + selectedDevice != NO_DEVICE_SELECTED -> ConnectionUiState.CONNECTING - else -> 0 + else -> ConnectionUiState.NO_DEVICE } Crossfade(targetState = uiState, label = "connection_state") { state -> when (state) { - 2 -> + ConnectionUiState.CONNECTED_WITH_NODE -> ConnectedDeviceContent( ourNode = ourNode, regionUnset = regionUnset, @@ -191,7 +193,7 @@ fun ConnectionsScreen( }, ) - 1 -> + ConnectionUiState.CONNECTING -> ConnectingDeviceContent( connectionState = connectionState, selectedDevice = selectedDevice, @@ -208,7 +210,9 @@ fun ConnectionsScreen( } var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) } - LaunchedEffect(Unit) { DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } } + LaunchedEffect(selectedDevice) { + DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } + } val supportedDeviceTypes = scanModel.supportedDeviceTypes @@ -369,3 +373,15 @@ private fun NoDeviceContent() { ) } } + +/** Visual state for the connection screen's [Crossfade] animation. */ +private enum class ConnectionUiState { + /** No device is selected. */ + NO_DEVICE, + + /** A device is selected or we are actively connecting. */ + CONNECTING, + + /** Connected with node info available. */ + CONNECTED_WITH_NODE, +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt index 53cec80b5..ebc981398 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt @@ -42,6 +42,10 @@ import org.meshtastic.core.resources.connecting import org.meshtastic.core.resources.disconnect import org.meshtastic.core.ui.theme.StatusColors.StatusRed +/** + * Displays the currently connecting (or connected) device with its name, address, connection status, and a disconnect + * button. + */ @Composable fun ConnectingDeviceInfo( connectionState: ConnectionState, diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt index 6f291d68a..04e9ac03e 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt @@ -53,7 +53,7 @@ class ScannerViewModelTest { @BeforeTest fun setUp() { - every { radioInterfaceService.isMockInterface() } returns false + every { radioInterfaceService.isMockTransport() } returns false every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null) every { radioInterfaceService.supportedDeviceTypes } returns emptyList() From 6da9f088a9c05818b4ad2275dc4f77dacf0c47fd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 06:43:45 -0500 Subject: [PATCH 020/114] chore(deps): update softprops/action-gh-release action to v3 (#5081) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77687a105..40d8e40f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -328,7 +328,7 @@ jobs: path: ./artifacts - name: Create or Update GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ inputs.tag_name }} target_commitish: ${{ inputs.commit_sha || github.sha }} @@ -341,7 +341,7 @@ jobs: - name: Create or Update internal GitHub Release continue-on-error: true if: ${{ env.INTERNAL_BUILDS_HOST != '' }} - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: repository: ${{ secrets.INTERNAL_BUILDS_HOST }} token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }} From 9281324be345eb6b65713812d633bf7dd20ede40 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 06:44:03 -0500 Subject: [PATCH 021/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5082) --- .../composeResources/values-bg/strings.xml | 32 +++++++++++++++++++ .../composeResources/values-de/strings.xml | 32 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index 6086edcdf..ff2ceced6 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -348,12 +348,24 @@ Вижте на картата Показване на %1$d/%2$d възела Продължителност: %1$s s + Няма отговор + Натоварване 1m + Натоварване 5m + Натоварване 15m + Средно натоварване на системата за една минута + Средно натоварване на системата за пет минути + Средно натоварване на системата за петнадесет минути + Налична системна памет в байтове 24Ч Макс + Мин + Ср + Разгъване на диаграмата + Свиване на диаграмата Неизвестна възраст Копиране Критичен сигнал! @@ -366,6 +378,11 @@ Канал 1 Канал 2 Канал 3 + Канал 4 + Канал 5 + Канал 6 + Канал 7 + Канал 8 Текущ Напрежение Сигурни ли сте? @@ -375,6 +392,7 @@ Известия за изтощена батерия Батерията е изтощена: %1$s Известия за изтощена батерия (любими възли) + Баро Активиран Последно чут: %2$s
Последна позиция: %3$s
Батерия: %4$s]]>
Потребител @@ -515,6 +533,8 @@ Серийната връзка е активирана Echo е активирано Серийна скорост на предаване + RX + TX Сериен режим Брой записи @@ -539,6 +559,11 @@ Налягане Разстояние Вятър + Скорост на вятъра + Порив на вятъра + Посока на вятъра + Дъжд (1ч) + Дъжд (24 ч) Тегло Радиация @@ -665,6 +690,12 @@ Съобщение Въведете съобщение PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s Осигуряване на Wi-Fi за mPWRD-OS Bluetooth устройства Свързано устройство @@ -908,6 +939,7 @@ Забележка Тема: %1$s, Език: %2$s Налични файлове (%1$d): + - %1$s (%2$d байта) Свързване Готово Осигуряване на Wi-Fi за mPWRD-OS diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 8a344ff18..a358cb984 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -411,13 +411,27 @@ Dauer: %1$s s Route zum Zielort:\n\n Route zurück zu uns:\n\n + Sprungweite Hinweg + Sprungweite Rückweg + Rundstrecke Keine Antwort + Last 1 Min. + Last 5 Min. + Last 15 Min. + Durchschnittliche Systemlast von 1 Minute + Durchschnittliche Systemlast von 5 Minuten + Durchschnittliche Systemlast von 15 Minuten + Verfügbarer Systemspeicher in Bytes 1 Stunde 24H 1 Woche 2 Wochen 1 Monat Maximal + Minimum + Durchschnitt + Diagramm einblenden + Diagramm ausblenden Alter unbekannt Kopie Warnklingelzeichen! @@ -431,6 +445,11 @@ Kanal 1 Kanal 2 Kanal 3 + Kanal 4 + Kanal 5 + Kanal 6 + Kanal 7 + Kanal 8 Strom Spannung Sind Sie sicher? @@ -656,6 +675,8 @@ Serielle Schnittstelle aktiviert Echo aktiviert Serielle Baudrate + Empfang + Senden Zeitlimit erreicht Serieller Modus Seriellen Anschluss der Konsole überschreiben @@ -691,6 +712,11 @@ Lux Wind Windgeschwindigkeit + Windböen + Windstille + Windrichtung + Regen (1 Std.) + Regen (24 Std.) Gewicht Strahlung @@ -832,6 +858,12 @@ Eine Nachricht schreiben Benutzerzählerdaten Besucher + Besucher: %1$d + B:%1$d + W:%1$d + Besucher: %1$s + BLE: %1$s + WLAN: %1$s Keine Daten für den Besucherzähler verfügbar. WLAN Unterstützung für mPWRD-OS Bluetooth Geräte From 7ca7179197ea6e9967d95f03cf3e5e52f8d46c28 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:45:11 -0500 Subject: [PATCH 022/114] build: migrate Compose dependencies to Compose Multiplatform (#5084) --- app/build.gradle.kts | 6 +++--- .../src/main/kotlin/KmpFeatureConventionPlugin.kt | 6 +++--- .../main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt | 5 ++--- core/barcode/build.gradle.kts | 6 +++--- feature/intro/build.gradle.kts | 1 - feature/map/build.gradle.kts | 1 - feature/widget/build.gradle.kts | 2 +- gradle/libs.versions.toml | 2 +- 8 files changed, 13 insertions(+), 16 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 77302534e..ed9f3a766 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -243,9 +243,9 @@ dependencies { implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.material) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.ui.text) + implementation(libs.compose.multiplatform.material3) + implementation(libs.compose.multiplatform.ui.tooling.preview) + implementation(libs.compose.multiplatform.ui) implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.appwidget.preview) implementation(libs.androidx.glance.material3) diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index 4d02a630a..33278df93 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -62,10 +62,10 @@ class KmpFeatureConventionPlugin : Plugin { // Common Android Compose dependencies implementation(libs.library("accompanist-permissions")) implementation(libs.library("androidx-activity-compose")) - implementation(libs.library("androidx-compose-material3")) + implementation(libs.library("compose-multiplatform-material3")) - implementation(libs.library("androidx-compose-ui-text")) - implementation(libs.library("androidx-compose-ui-tooling-preview")) + implementation(libs.library("compose-multiplatform-ui")) + implementation(libs.library("compose-multiplatform-ui-tooling-preview")) } sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt index 40cbe83fa..bd620f6a5 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt @@ -31,11 +31,10 @@ internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { if (hasAndroidTest) { "androidTestImplementation"(platform(bom)) } - "debugImplementation"(libs.library("androidx-compose-ui-tooling")) - "implementation"(libs.library("androidx-compose-runtime")) + "debugImplementation"(libs.library("compose-multiplatform-ui-tooling")) + "implementation"(libs.library("compose-multiplatform-runtime")) "runtimeOnly"(libs.library("androidx-compose-runtime-tracing")) - "implementation"(libs.library("compose-multiplatform-runtime")) "implementation"(libs.library("compose-multiplatform-resources")) // Add Espresso explicitly to avoid version mismatch issues on newer Android versions diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts index c2533dd3c..c8dbc078e 100644 --- a/core/barcode/build.gradle.kts +++ b/core/barcode/build.gradle.kts @@ -33,9 +33,9 @@ dependencies { implementation(projects.core.ui) implementation(libs.androidx.activity.compose) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.compose.ui) + implementation(libs.compose.multiplatform.material3) + implementation(libs.compose.multiplatform.runtime) + implementation(libs.compose.multiplatform.ui) implementation(libs.accompanist.permissions) implementation(libs.kermit) diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts index 242c75bcc..1dc180a42 100644 --- a/feature/intro/build.gradle.kts +++ b/feature/intro/build.gradle.kts @@ -42,7 +42,6 @@ kotlin { val androidHostTest by getting { dependencies { implementation(libs.junit) - implementation(project.dependencies.platform(libs.androidx.compose.bom)) implementation(libs.kotlinx.coroutines.test) } } diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index fff9fe21b..ebd5ec2c9 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -47,7 +47,6 @@ kotlin { val androidHostTest by getting { dependencies { implementation(libs.junit) - implementation(project.dependencies.platform(libs.androidx.compose.bom)) implementation(libs.kotlinx.coroutines.test) } } diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index a11e4ee7d..8d2045469 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -33,7 +33,7 @@ dependencies { implementation(projects.core.resources) implementation(projects.core.repository) - implementation(libs.androidx.compose.ui) // LocalConfiguration, LocalDensity + implementation(libs.compose.multiplatform.ui) // LocalConfiguration, LocalDensity implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) implementation(libs.androidx.glance.preview) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b11700f95..05bfabf1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -119,7 +119,7 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose -androidx-compose-bom = { module = "androidx.compose:compose-bom", version = "2026.03.01" } +androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.03.01" } androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } # Only used by deprecated mesh_service_example — remove when that module is deleted androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } From 916eb51b94163f7639b91040478c3cd06df5e52b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:01:40 -0500 Subject: [PATCH 023/114] chore(deps): update androidx.compose:compose-bom-alpha to v2026.04.00 (#5086) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05bfabf1c..b948d8d16 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -119,7 +119,7 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose -androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.03.01" } +androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.04.00" } androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } # Only used by deprecated mesh_service_example — remove when that module is deleted androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } From d03e61af6f832fd3b027bbc31334ab6ac9cddca0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:05:52 -0500 Subject: [PATCH 024/114] fix(build): remove Compose BOM to resolve compileSdk 37 conflict (#5088) --- .../src/main/kotlin/KmpFeatureConventionPlugin.kt | 3 --- .../org/meshtastic/buildlogic/AndroidCompose.kt | 5 ----- gradle/libs.versions.toml | 15 ++++----------- mesh_service_example/build.gradle.kts | 2 +- 4 files changed, 5 insertions(+), 20 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index 33278df93..4fef5c6f4 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -56,9 +56,6 @@ class KmpFeatureConventionPlugin : Plugin { } sourceSets.getByName("androidMain").dependencies { - // Compose BOM for consistent Android Compose versions - implementation(target.dependencies.platform(libs.library("androidx-compose-bom"))) - // Common Android Compose dependencies implementation(libs.library("accompanist-permissions")) implementation(libs.library("androidx-activity-compose")) diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt index bd620f6a5..1d4e2ea56 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt @@ -26,11 +26,6 @@ internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { val hasAndroidTest = project.projectDir.resolve("src/androidTest").exists() dependencies { - val bom = libs.library("androidx-compose-bom") - "implementation"(platform(bom)) - if (hasAndroidTest) { - "androidTestImplementation"(platform(bom)) - } "debugImplementation"(libs.library("compose-multiplatform-ui-tooling")) "implementation"(libs.library("compose-multiplatform-runtime")) "runtimeOnly"(libs.library("androidx-compose-runtime-tracing")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b948d8d16..1cd010b7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -118,18 +118,11 @@ androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.2" } androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } -# AndroidX Compose -androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.04.00" } -androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } # Only used by deprecated mesh_service_example — remove when that module is deleted -androidx-compose-material3 = { module = "androidx.compose.material3:material3" } -androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +# AndroidX Compose (explicit versions — BOM removed to avoid transitive compileSdk conflicts with CMP adaptive fork) +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended", version = "1.7.8" } # Only used by deprecated mesh_service_example — remove when that module is deleted androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidxTracing" } -androidx-compose-ui = { module = "androidx.compose.ui:ui" } -androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } -androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } -androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } -androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } -androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.11.0-rc01" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.11.0-rc01" } # Compose Multiplatform compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } diff --git a/mesh_service_example/build.gradle.kts b/mesh_service_example/build.gradle.kts index 843eeff85..793735dda 100644 --- a/mesh_service_example/build.gradle.kts +++ b/mesh_service_example/build.gradle.kts @@ -44,7 +44,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.jetbrains.lifecycle.viewmodel.compose) implementation(libs.jetbrains.lifecycle.runtime) - implementation(libs.androidx.compose.material3) + implementation(libs.compose.multiplatform.material3) implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.material) From eeed780e51e0a56d80e3083b48080fe8b6092dc5 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:29:05 -0500 Subject: [PATCH 025/114] chore(ai): modernize and unify agent tooling and instructions (#5087) --- .copilotignore | 27 ++ .gemini/settings.json | 5 + .../copilot-commit-message-instructions.md | 27 ++ .github/copilot-instructions.md | 8 +- .github/copilot-pull-request-instructions.md | 18 ++ .../android-source-set.instructions.md | 11 + .../instructions/build-logic.instructions.md | 10 + .../instructions/ci-workflows.instructions.md | 14 + .../instructions/kmp-common.instructions.md | 17 ++ .gitignore | 2 + .skills/code-review/SKILL.md | 67 +++++ .skills/compose-ui/SKILL.md | 31 +++ .skills/implement-feature/SKILL.md | 37 +++ .skills/kmp-architecture/SKILL.md | 55 ++++ .skills/navigation-and-di/SKILL.md | 37 +++ .skills/project-overview/SKILL.md | 76 ++++++ .skills/testing-ci/SKILL.md | 97 +++++++ AGENTS.md | 255 ++++-------------- CLAUDE.md | 9 + GEMINI.md | 8 +- SOUL.md | 2 +- docs/agent-playbooks/README.md | 52 ---- .../di-navigation3-anti-patterns-playbook.md | 58 ---- .../kmp-source-set-bridging-playbook.md | 45 ---- docs/agent-playbooks/task-playbooks.md | 113 -------- .../testing-and-ci-playbook.md | 88 ------ docs/kmp-status.md | 2 +- 27 files changed, 604 insertions(+), 567 deletions(-) create mode 100644 .copilotignore create mode 100644 .gemini/settings.json create mode 100644 .github/copilot-commit-message-instructions.md create mode 100644 .github/copilot-pull-request-instructions.md create mode 100644 .github/instructions/android-source-set.instructions.md create mode 100644 .github/instructions/build-logic.instructions.md create mode 100644 .github/instructions/ci-workflows.instructions.md create mode 100644 .github/instructions/kmp-common.instructions.md create mode 100644 .skills/code-review/SKILL.md create mode 100644 .skills/compose-ui/SKILL.md create mode 100644 .skills/implement-feature/SKILL.md create mode 100644 .skills/kmp-architecture/SKILL.md create mode 100644 .skills/navigation-and-di/SKILL.md create mode 100644 .skills/project-overview/SKILL.md create mode 100644 .skills/testing-ci/SKILL.md create mode 100644 CLAUDE.md delete mode 100644 docs/agent-playbooks/README.md delete mode 100644 docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md delete mode 100644 docs/agent-playbooks/kmp-source-set-bridging-playbook.md delete mode 100644 docs/agent-playbooks/task-playbooks.md delete mode 100644 docs/agent-playbooks/testing-and-ci-playbook.md diff --git a/.copilotignore b/.copilotignore new file mode 100644 index 000000000..02ec3ad1d --- /dev/null +++ b/.copilotignore @@ -0,0 +1,27 @@ +# Ignore build artifacts and generated files from Copilot indexing +# This saves context window tokens and prevents Copilot from hallucinating off of minified code. + +# Build directories +**/build/** +.gradle/ +.idea/ + +# Android generated files +**/generated/** +.cxx/ +.externalNativeBuild/ + +# Git history & worktrees +.git/ +.worktrees/ + +# Protobuf (Prevents Copilot from suggesting raw protobuf byte buffers) +core/proto/ + +# Environment and secrets +local.properties +secrets.properties +*.jks + +# Agent References (Prevents pollution of project space with external code) +.agent_refs/ diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 000000000..5e535b215 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,5 @@ +{ + "context": { + "fileName": ["AGENTS.md", "GEMINI.md"] + } +} diff --git a/.github/copilot-commit-message-instructions.md b/.github/copilot-commit-message-instructions.md new file mode 100644 index 000000000..93c242d16 --- /dev/null +++ b/.github/copilot-commit-message-instructions.md @@ -0,0 +1,27 @@ +# GitHub Copilot Commit Message Instructions + + +You are an expert Git maintainer enforcing Conventional Commits. + + + +1. **Format:** Use the Conventional Commits format: `(): ` (Replace angle brackets with actual text, do NOT output angle brackets). +2. **Types allowed:** + - `feat` (new feature for the user, not a new feature for build script) + - `fix` (bug fix for the user, not a fix to a build script) + - `docs` (changes to the documentation) + - `style` (formatting, missing semi colons, etc; no production code change) + - `refactor` (refactoring production code, e.g. KMP migration, extracting to commonMain) + - `test` (adding missing tests, refactoring tests; no production code change) + - `chore` (updating grunt tasks etc; no production code change) +3. **Scope:** Use the module or logical component as the scope (e.g., `ui`, `navigation`, `ble`, `firmware`, `deps`, `ai`). +4. **Subject line:** + - Use the imperative, present tense: "change" not "changed" nor "changes". + - Do not capitalize the first letter. + - Do not use a period (.) at the end. + - Keep it under 50 characters if possible. +5. **Body (Optional but recommended for large diffs):** + - Leave one blank line after the subject. + - Explain *why* the change was made, not just *what* changed. + - If migrating to KMP or extracting to `commonMain`, explicitly state "Decoupled from Android framework". + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2e60f3dff..e856cbe8f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ -# Meshtastic Android - Agent Guide +# Meshtastic Android - GitHub Copilot Guide -**Canonical instructions live in [`AGENTS.md`](../AGENTS.md).** This file exists at `.github/copilot-instructions.md` so GitHub Copilot discovers it automatically. +> **Note:** The canonical instructions for all AI Agents have been deduplicated. -See [AGENTS.md](../AGENTS.md) for architecture, conventions, execution protocol, and coding standards. -See [docs/agent-playbooks/README.md](../docs/agent-playbooks/README.md) for version baselines and task recipes. +You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. +After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks. diff --git a/.github/copilot-pull-request-instructions.md b/.github/copilot-pull-request-instructions.md new file mode 100644 index 000000000..8e79d63d2 --- /dev/null +++ b/.github/copilot-pull-request-instructions.md @@ -0,0 +1,18 @@ +# GitHub Copilot Pull Request Instructions + + +You are an expert open-source maintainer. Your goal is to write clear, professional, and highly structured Pull Request descriptions based on the provided diffs. + + + +1. **Remove Boilerplate:** Always delete the "tips" section at the top of the `PULL_REQUEST_TEMPLATE.md` before generating your text. +2. **Context First:** Start with a clear, 1-2 sentence summary of *why* this change is being made. If the branch name or commits reference an issue (e.g., `fix-1234`), explicitly add `Fixes #1234` or `Resolves #1234`. +3. **Structured Changes:** Break down the code changes into bullet points categorized by: + - 🌟 **New Features** (UI, modules, logic) + - 🛠️ **Refactoring & Architecture** (KMP migrations, Koin DI updates) + - 🐛 **Bug Fixes** + - 🧹 **Chores** (Dependencies, formatting, docs) +4. **Architecture Callouts:** If the diff includes moving files from `androidMain` to `commonMain`, or migrating from Android Views to Compose, highlight this as a "KMP Migration Milestone". +5. **Testing Callouts:** If the diff includes changes to `commonTest` or mentions tests, add a section called "Testing Performed" and list the tests that were added/modified. +6. **No "Magic" Text:** Do not invent URLs or insert fake image placeholders. Leave the HTML comment block for images intact so the user can manually add their screenshots. + diff --git a/.github/instructions/android-source-set.instructions.md b/.github/instructions/android-source-set.instructions.md new file mode 100644 index 000000000..6179bc61a --- /dev/null +++ b/.github/instructions/android-source-set.instructions.md @@ -0,0 +1,11 @@ +--- +applyTo: "**/androidMain/**/*.kt" +--- + +# Android Source-Set Rules + +- This is `androidMain` — Android framework imports (`android.*`, `java.*`) are allowed here. +- Do NOT put business logic here. Business logic belongs in `commonMain`. +- If you find identical pure-Kotlin logic in both `androidMain` and `jvmMain`, extract it to `commonMain`. +- Use `expect`/`actual` only for small platform primitives. Prefer interfaces + DI. +- Keep `expect` declarations in `FileIo.kt` and shared helpers in `FileIoUtils.kt` to avoid JVM duplicate class errors. diff --git a/.github/instructions/build-logic.instructions.md b/.github/instructions/build-logic.instructions.md new file mode 100644 index 000000000..d61fa34b8 --- /dev/null +++ b/.github/instructions/build-logic.instructions.md @@ -0,0 +1,10 @@ +--- +applyTo: "build-logic/**/*.kt" +--- + +# Build-Logic Convention Plugin Rules + +- Prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). +- Avoid `afterEvaluate` unless there is no viable lazy alternative. +- Check `gradle/libs.versions.toml` for version catalog aliases before adding new ones. +- Convention plugins: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`. diff --git a/.github/instructions/ci-workflows.instructions.md b/.github/instructions/ci-workflows.instructions.md new file mode 100644 index 000000000..55a72b328 --- /dev/null +++ b/.github/instructions/ci-workflows.instructions.md @@ -0,0 +1,14 @@ +--- +applyTo: "**/*.yml" +excludeAgent: "code-review" +--- + +# CI Workflow Rules + +- Prefer explicit Gradle task paths (`app:lintFdroidDebug`) over shorthand (`lintDebug`). +- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values. +- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`. +- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise. +- Desktop build matrix: `macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`. +- Lightweight jobs (labelers, triage, stale): use `ubuntu-24.04-arm` runners. +- Gradle-heavy jobs: use `ubuntu-24.04` runners. diff --git a/.github/instructions/kmp-common.instructions.md b/.github/instructions/kmp-common.instructions.md new file mode 100644 index 000000000..235d5826d --- /dev/null +++ b/.github/instructions/kmp-common.instructions.md @@ -0,0 +1,17 @@ +--- +applyTo: "**/commonMain/**/*.kt" +--- + +# KMP commonMain Rules + +- NEVER import `java.*` or `android.*` in `commonMain`. +- Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO`. +- Use Okio (`BufferedSource`/`BufferedSink`) instead of `java.io.*`. +- Use `kotlinx.coroutines.sync.Mutex` instead of `java.util.concurrent.locks.*`. +- Use `atomicfu` or Mutex-guarded `mutableMapOf()` instead of `ConcurrentHashMap`. +- Use `jetbrains-*` catalog aliases for lifecycle/navigation dependencies. +- Use `compose-multiplatform-*` catalog aliases for CMP dependencies. +- Never use plain `androidx.compose` dependencies in `commonMain`. +- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings. +- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`. +- Check `gradle/libs.versions.toml` before adding dependencies. diff --git a/.gitignore b/.gitignore index 97dbb7b24..8447bc7f7 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ wireless-install.sh .worktrees/ /firebase-debug.log.jdk/ firebase-debug.log +.agent_plans/ +.agent_refs/ diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md new file mode 100644 index 000000000..08caa95be --- /dev/null +++ b/.skills/code-review/SKILL.md @@ -0,0 +1,67 @@ +# Skill: Code Review + +## Description +Perform comprehensive and precise code reviews for the `Meshtastic-Android` project. This skill ensures that incoming changes adhere strictly to the project's architecture guidelines, Kotlin Multiplatform (KMP) conventions, Modern Android Development (MAD) standards, and Jetpack Compose Multiplatform (CMP) best practices. + +## Context & Prerequisites +The `Meshtastic-Android` codebase is a highly modernized Kotlin Multiplatform (KMP) application designed for off-grid, decentralized mesh networks. +- **Language:** Kotlin (primary), JDK 21 required. +- **Architecture:** KMP core with Android and Desktop host shells. +- **UI:** Jetpack Compose Multiplatform (CMP) and Material 3 Adaptive. +- **Navigation:** JetBrains Navigation 3 (Scene-based). +- **DI:** Koin Annotations (with K2 compiler plugin). +- **Async & I/O:** Kotlin Coroutines, Flow, Okio, Ktor. + +## Code Review Checklist + +When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix. + +### 1. KMP Architecture & Source Set Boundaries +- [ ] **No Platform Bleed:** Ensure absolutely no `java.*` or `android.*` imports exist in `commonMain` source sets. +- [ ] **KMP Native Alternatives:** Verify the use of KMP alternatives for standard JVM libraries: + - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex` + - `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()` + - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`) + - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` or `expect`/`actual` +- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`. +- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target. +- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities. + +### 2. UI & Compose Multiplatform (CMP) +- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine. +- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. +- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`. +- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules. +- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp). + +### 3. Navigation & State +- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(...)`). Flag any graphs defined in platform-specific source sets. +- [ ] **Navigation Host:** Ensure `MeshtasticNavDisplay` (from `core:ui/commonMain`) is used as the host instead of invoking `NavDisplay` directly. Host modules should not configure `entryDecorators` themselves. +- [ ] **ViewModel Scoping:** ViewModels obtained via `koinViewModel()` must be inside `entry` blocks to correctly tie to the backstack lifetime. + +### 4. Dependency Injection (Koin Annotations) +- [ ] **Annotation Usage:** Ensure Koin is configured via annotations (`@Single`, `@Factory`, `@KoinViewModel`). +- [ ] **Root Assembly:** Confirm that the root Koin DI graph is only assembled in host shells (`app` and `desktop`). + +### 5. Networking, DB & I/O +- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp. +- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers. +- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`. +- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions. + +### 6. Dependency Catalog Aliases +- [ ] **JetBrains vs. AndroidX:** + - In `commonMain`: Must use `jetbrains-*` aliases (e.g., `jetbrains-lifecycle-*`, `jetbrains-navigation3-ui`). + - In `androidMain`: Can use `androidx-*` or `jetbrains-*` as appropriate, but do not mix them up in `commonMain`. +- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules. + +### 7. Testing +- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`. +- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. +- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues. + +## Review Output Guidelines +1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern. +2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio"). +3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous. +4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions. diff --git a/.skills/compose-ui/SKILL.md b/.skills/compose-ui/SKILL.md new file mode 100644 index 000000000..d2e79c542 --- /dev/null +++ b/.skills/compose-ui/SKILL.md @@ -0,0 +1,31 @@ +# Skill: Compose Multiplatform (CMP) UI + +## Description +Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive. + +## 1. UI Components & Layouts +- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets. +- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups. +- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. +- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`. +- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`. + +## 2. Strings & Resources +- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings. +- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context. +- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer). + - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`). + - **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings. +- **Workflow to Add a String:** + 1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`. + 2. Use the generated `org.meshtastic.core.resources.` symbol. + 3. Validate UI presentation. + +## 3. Tooling & Capabilities +- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules. +- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code. + +## Reference Anchors +- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml` +- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` +- **Provider wiring:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` diff --git a/.skills/implement-feature/SKILL.md b/.skills/implement-feature/SKILL.md new file mode 100644 index 000000000..1efa3caa0 --- /dev/null +++ b/.skills/implement-feature/SKILL.md @@ -0,0 +1,37 @@ +# Skill: Implement a Feature + +## Description +A step-by-step workflow for implementing a new feature in the Meshtastic-Android codebase, ensuring KMP compatibility and proper architecture. + +## Workflow + +### 1. Update Dependencies & Aliases +- Check `gradle/libs.versions.toml` before adding libraries. +- Use `jetbrains-*` aliases for lifecycle/navigation/adaptive dependencies in `commonMain`. +- Use `compose-multiplatform-*` aliases for CMP dependencies. + +### 2. Define the State & ViewModels +- Follow MVI/UDF patterns. +- Extend shared ViewModel logic in `feature//src/commonMain/kotlin/org/meshtastic/feature//ViewModel.kt`. +- Use `stateInWhileSubscribed` (from `core:ui`) for sharing state flows. +- Keep the ViewModel free of Android framework dependencies. + +### 3. Build the UI +- Use Jetpack Compose Multiplatform (CMP). +- Define strings in `core:resources` (see the `compose-ui` skill). +- Support adaptive layouts (Large/XL breakpoints). + +### 4. Wire Navigation & DI +- Define typed route objects in `core:navigation`. +- Export the navigation graph as an extension function on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.myFeatureGraph()`). +- Add the required DI bindings via Koin Annotations (`@Factory`, `@Single`, `@KoinViewModel`) in `commonMain`. +- **CRITICAL:** Ensure the module is registered in the app root graphs (`AppKoinModule.kt`, `DesktopKoinModule.kt`) and the navigation is injected into the root entry provider in the host shell. + +### 5. Validate Platform Separation +- If you need a platform-specific API (like camera or specific mapping SDK), define an interface in `commonMain`, implement it in the host shell, and inject it via `CompositionLocal` or Koin. + +### 6. Verify Locally +- Run the baseline checks (see `testing-ci` skill): + ```bash + ./gradlew spotlessCheck detekt assembleDebug test allTests + ``` diff --git a/.skills/kmp-architecture/SKILL.md b/.skills/kmp-architecture/SKILL.md new file mode 100644 index 000000000..805d9f2f9 --- /dev/null +++ b/.skills/kmp-architecture/SKILL.md @@ -0,0 +1,55 @@ +# Skill: KMP Architecture & Source-Set Bridging + +## Description +Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstractions, networking, database, and platform integration rules. + +## 1. Source-Set Boundaries +- **`commonMain`:** All business logic, DB entities, API network logic, ViewModels, and UI rendering. NO `java.*` or `android.*` imports. +- **`androidMain`:** Android framework integration (`Context`, system services, NFC hardware, BLE Android bindings). +- **`jvmMain` / `jvmAndroidMain`:** Shared JVM code between Android and Desktop. Uses the `meshtastic.kmp.jvm.android` convention plugin to bridge `jvm` and `android` source sets without manual `dependsOn` hacks. +- **`app` / `desktop`:** Host shells. Responsible for Koin DI root wiring, `MainKoinModule`, host-level UI themes, and running the `MeshtasticNavDisplay`. + +## 2. Bridging Strategies +- **Interface + DI (Preferred):** Expose an interface in `core:repository` or `core:ui` (e.g. `LocationRepository`, `MapViewProvider`), implement it in `androidMain` or the host `app`, and bind it via Koin or `CompositionLocal`. +- **`expect`/`actual` (Restricted):** Use only when a platform API cannot be abstracted cleanly (e.g. low-level File I/O mappings, `uppercase()` Locale helpers). Avoid deep class hierarchies using `expect`/`actual`. + - **Naming:** Keep `expect` in `FileIo.kt`, but put shared helpers in `FileIoUtils.kt` to prevent JVM duplicate class errors. +- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper. + +## 3. Core Libraries & Constraints +- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. +- **Standard Library Replacements:** + - `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`. + - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`. + - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`). +- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging. +- **BLE:** Route through `core:ble` using **Kable**. +- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`. + +## 4. Hierarchy & Source-Set Conventions +- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model. +- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient. +- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract to `commonMain`. Examples: `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BaseRadioTransportFactory`. + +## 5. Dependency Catalog Aliases +- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. +- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in `commonMain`. +- **Dependencies:** Always check `gradle/libs.versions.toml` before assuming a library is available. + +## 6. I/O & Serialization +- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision. +- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. + +## 7. Build-Logic Conventions +- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. + +## 8. Onboarding a New Target (Desktop/iOS) +1. Ensure all new logic compiles against the KMP core (`jvm()`, `iosArm64()`, etc.). +2. Do not use platform-specific constructs in `commonMain` or you break the iOS/Desktop builds. +3. Test using `kmpSmokeCompile` to verify cross-platform compilation. +4. For desktop wiring, copy the pattern in `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations. + +## Reference Anchors +- **Shared Okio I/O:** `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt` +- **Desktop DI Stubs:** `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt` +- **Version Catalog:** `gradle/libs.versions.toml` +- **Convention Plugins:** `build-logic/convention/` diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md new file mode 100644 index 000000000..557db4717 --- /dev/null +++ b/.skills/navigation-and-di/SKILL.md @@ -0,0 +1,37 @@ +# Skill: DI and Navigation 3 Architecture + +## Description +This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Navigation 3 (1.1.x) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase. + +## Dependency Injection (Koin) + +### Guidelines +1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature. +2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`. +3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead. +4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs. + +### Anti-Patterns +- **A1 Module Compile Safety:** Do **not** enable A1 `compileSafety`. We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) because of our decoupled Clean Architecture design (interfaces in one module, implemented in another). +- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values. + +## Navigation 3 + +### Guidelines +1. **Types:** Use Navigation 3 types consistently (`NavKey`, `NavBackStack`, `EntryProviderScope`). +2. **Typed Routes:** Keep route definitions in `core:navigation/src/commonMain/.../Routes.kt` as `@Serializable sealed interface` hierarchies. Don't use ad-hoc strings. +3. **Graph Assembly:** Define feature navigation graphs as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(backStack)`). +4. **Host Integration:** Use `MeshtasticNavDisplay` (from `core:ui/commonMain`) as the Navigation 3 host. Do not configure decorators manually inside feature modules. +5. **Back Handlers:** Use `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures in multiplatform code. Do not use Android's `BackHandler`. +6. **Deep Links:** Use `DeepLinkRouter.route()` in `core:navigation` to synthesize typed backstacks from RESTful paths. + +### Anti-Patterns +- **Single Backstack for Multiple Tabs:** Do **not** use a single `NavBackStack` list for multiple tabs. Use `MultiBackstack` (from `core:navigation`). +- **Decorator Reuse Across Tabs:** Do **not** reuse the same `NavEntryDecorator` instances across different backstacks. When rendering an active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance to prevent permanent `ViewModelStore` destruction. +- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack` directly with `add(...)` and `removeLastOrNull()`. + +## Reference Anchors +- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` +- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` +- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` +- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md new file mode 100644 index 000000000..0ceade61a --- /dev/null +++ b/.skills/project-overview/SKILL.md @@ -0,0 +1,76 @@ +# Skill: Project Overview & Codebase Map + +## Description +High-level project context, module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android. + +## 1. Project Vision & Architecture +Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling expansion to iOS and Desktop while maintaining a high-performance native Android experience. + +- **Language:** Kotlin (primary), AIDL. +- **Build System:** Gradle (Kotlin DSL). JDK 21 is REQUIRED. +- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0). +- **Flavors:** + - `fdroid`: Open source only, no tracking/analytics. + - `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production"). +- **KMP Modules:** Most `core:*` modules declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all. +- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. + +## 2. Codebase Map + +| Directory | Description | +| :--- | :--- | +| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. | +| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). | +| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). | +| `docs/` | Architecture docs and agent playbooks. See `docs/kmp-status.md` and `docs/roadmap.md` for current status. | +| `core/model` | Domain models and common data structures. | +| `core:proto` | Protobuf definitions (Git submodule). | +| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. | +| `core:database` | Room KMP database implementation. | +| `core:datastore` | Multiplatform DataStore for preferences. | +| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). | +| `core:domain` | Pure KMP business logic and UseCases. | +| `core:data` | Core manager implementations and data orchestration. | +| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). | +| `core:di` | Common DI qualifiers and dispatchers. | +| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies. `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence. | +| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | +| `core:service` | KMP service layer; Android bindings stay in `androidMain`. | +| `core:api` | Public AIDL/API integration module for external clients. | +| `core:prefs` | KMP preferences layer built on DataStore abstractions. | +| `core:barcode` | Barcode scanning (Android-only). | +| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. | +| `core/ble/` | Bluetooth Low Energy stack using Kable. | +| `core/resources/` | Centralized string and image resources (Compose Multiplatform). | +| `core/testing/` | Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules. | +| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP except `widget`. Use `meshtastic.kmp.feature` convention plugin. | +| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. | +| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | +| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. | +| `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app. See `core/api/README.md` for the current integration guide. | + +## 3. Namespacing +- **Standard:** Use the `org.meshtastic.*` namespace for all code. +- **Legacy:** Maintain the `com.geeksville.mesh` Application ID. + +## 4. Environment Setup +1. **JDK 21 MUST be used** to prevent Gradle sync/build failures. +2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`: + ```properties + MAPS_API_KEY=dummy_key + datadogApplicationId=dummy_id + datadogClientToken=dummy_token + ``` + +## 5. Troubleshooting +- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts. +- **Missing Secrets:** Check `local.properties` (see Environment Setup above). +- **JDK Version:** JDK 21 is required. +- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist. +- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`). + +## Reference Anchors +- **KMP Migration Status:** `docs/kmp-status.md` +- **Roadmap:** `docs/roadmap.md` +- **Architecture Decision Records:** `docs/decisions/` +- **Version Catalog:** `gradle/libs.versions.toml` diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md new file mode 100644 index 000000000..8342714de --- /dev/null +++ b/.skills/testing-ci/SKILL.md @@ -0,0 +1,97 @@ +# Skill: Testing and CI Verification + +## Description +Guidelines and commands for verifying code changes locally and understanding the Meshtastic-Android CI pipeline. Use this to determine which testing matrix is needed based on the change type. + +## 1) Baseline local verification order + +Run in this order for routine changes to ensure code formatting, analysis, and basic compilation: + +```bash +./gradlew clean +./gradlew spotlessCheck +./gradlew spotlessApply +./gradlew detekt +./gradlew assembleDebug +./gradlew test allTests +``` + +> **Why `test allTests` and not just `test`:** +> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and +> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules. +> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin. +> Conversely, `allTests` does **not** cover pure-Android modules (`:app`, `:core:api`, etc.), which is why both `test` and `allTests` are needed. + +*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.* + +## 2) Change-type verification matrix + +- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical. +- `UI text/resource` changes: `spotlessCheck`, `detekt`, `assembleDebug`. +- `feature/commonMain logic` changes: `spotlessCheck`, `detekt`, `test allTests`, `assembleDebug`. +- `navigation/DI wiring` changes: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`, plus flavor unit tests if available. + - If touching any KMP module, also run `kmpSmokeCompile`. +- `worker/service/background` changes: Broad tests, targeted WorkManager checks. +- `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`. + +## 3) Flavor and instrumentation checks + +Run these when relevant to map, provider, or flavor-specific behavior: + +```bash +./gradlew lintFdroidDebug lintGoogleDebug +./gradlew testFdroidDebug testGoogleDebug +./gradlew connectedAndroidTest +``` + +## 4) CI Pipeline Architecture + +CI is defined in `.github/workflows/reusable-check.yml` and structured as four parallel job groups: + +1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs. +2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`): + - `shard-core`: `allTests` for all `core:*` KMP modules. + - `shard-feature`: `allTests` for all `feature:*` KMP modules. + - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`). + Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. + Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. +3. **`android-check`** — Builds APKs and runs instrumented tests (depends on `lint-check`). +4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`). + +### Runner Strategy (Three Tiers) +- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times. +- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin for reproducibility. +- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging. + +### CI Gradle Properties +`gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides: +- `org.gradle.daemon=false` (single-use runners) +- `kotlin.incremental=false` (fresh checkouts) +- `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon +- VFS watching disabled, workers capped at 4 +- `org.gradle.isolated-projects=true` for better parallelism +- Disables unused Android build features (`resvalues`, `shaders`) + +### CI Conventions +- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks. +- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`. +- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations. +- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{version}-sdk{level}` — update when bumping version or SDK level. +- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing. +- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`). +- **`test-retry` plugin:** Applied to all module types (maxRetries=2, maxFailures=10). +- **`fail-fast: false`:** Test sharding does not cancel other shards on failure. +- **Explicit Gradle task paths:** Prefer `app:lintFdroidDebug` over shorthand `lintDebug` in CI. +- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`). +- **Cache writes:** Trusted on `main` and merge queue runs; other refs use read-only cache. +- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.). +- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone. + +## 5) Shell & Tooling Conventions +- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt. +- **Text Search:** Prefer `rg` (ripgrep) over `grep` or `find` for fast text searching across the codebase. + +## 6) Agent/Developer Guidance +- Start with the smallest set that validates your touched area. +- If unable to run full validation locally, report exactly what ran and what remains. +- Keep documentation synced in `AGENTS.md` and `.skills/` directories. diff --git a/AGENTS.md b/AGENTS.md index b8fe03945..92009df61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,208 +1,61 @@ -# Meshtastic Android - Agent Guide +# Meshtastic Android - Unified Agent & Developer Guide -This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project. + +You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Meshtastic-Android, a decentralized mesh networking application. You must maintain strict architectural boundaries, use Modern Android Development (MAD) standards, and adhere to Compose Multiplatform and JetBrains Navigation 3 patterns. + -For execution-focused recipes, see `docs/agent-playbooks/README.md`. + +- **Project Goal:** Decouple business logic from the Android framework for seamless multi-platform execution (Android, Desktop, iOS) while maintaining a high-performance native Android experience. +- **Language & Tech:** Kotlin 2.3+ (JDK 21 REQUIRED), Gradle Kotlin DSL, Ktor, Okio, Room KMP. +- **Core Architecture:** + - `commonMain` is pure KMP. `androidMain` is strictly for Android framework bindings. + - App root DI and graph assembly live in the `app` and `desktop` host shells. +- **Skills Directory:** You **MUST** consult the relevant `.skills/` module before executing work: + - `.skills/project-overview/` - Codebase map, module directory, namespacing, environment setup, troubleshooting. + - `.skills/kmp-architecture/` - Bridging, expect/actual, source-sets, catalog aliases, build-logic conventions. + - `.skills/compose-ui/` - Adaptive UI, placeholders, string resources. + - `.skills/navigation-and-di/` - JetBrains Navigation 3 & Koin 4.2+ annotations. + - `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties. + - `.skills/implement-feature/` - Step-by-step feature workflow. + - `.skills/code-review/` - PR validation checklist. +- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch. + -## 1. Project Vision & Architecture -Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience. + +- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing. +- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code. +- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first). +- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing: + `./gradlew clean spotlessCheck spotlessApply detekt assembleDebug test allTests` + -- **Language:** Kotlin (primary), AIDL. -- **Build System:** Gradle (Kotlin DSL). JDK 21 is REQUIRED. -- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0). -- **Flavors:** - - `fdroid`: Open source only, no tracking/analytics. - - `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production"). -- **Core Architecture:** Modern Android Development (MAD) with KMP core. - - **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all. - - **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. - - **UI:** Jetpack Compose Multiplatform (Material 3). - - **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`. - - **Navigation:** JetBrains Navigation 3 (Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`. - - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`. - - **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints. - - **Database:** Room KMP. + +- **Codebase Search:** Use whatever search and navigation tools your environment provides (file search, grep/ripgrep, symbol lookup, semantic search, etc.) to map out project boundaries before coding. Prefer `rg` (ripgrep) over `grep` or `find` for raw text search. +- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt. +- **Fetch Up-to-Date Docs:** If your environment supports web search, MCP servers, or documentation lookup tools, actively query them for the latest documentation on Koin 4.x, JetBrains Navigation 3, and Compose Multiplatform 1.11. +- **Clone Reference Repos:** If documentation is insufficient, use shell commands to clone bleeding-edge KMP dependency repositories into the local `.agent_refs/` directory (git-ignored) to inspect their source and test suites. Recommended: + - `https://github.com/JetBrains/kotlin-multiplatform-dev-docs` (Official Docs) + - `https://github.com/InsertKoinIO/koin` (Koin Annotations 4.x) + - `https://github.com/JetBrains/compose-multiplatform` (Navigation 3, Adaptive UI) + - `https://github.com/JuulLabs/kable` (BLE) + - `https://github.com/coil-kt/coil` (Coil 3 KMP) + - `https://github.com/ktorio/ktor` (Ktor Networking) +- **Formatting Hooks:** Always run `./gradlew spotlessApply` as an automatic formatting hook to fix style violations after editing. + -## 2. Codebase Map + +`AGENTS.md` is the single source of truth for agent instructions. Agent-specific files redirect here: +- `.github/copilot-instructions.md` — Copilot redirect to `AGENTS.md`. +- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions. +- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly. -| Directory | Description | -| :--- | :--- | -| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. | -| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). | -| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). | -| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. | -| `core/model` | Domain models and common data structures. | -| `core:proto` | Protobuf definitions (Git submodule). | -| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. | -| `core:database` | Room KMP database implementation. | -| `core:datastore` | Multiplatform DataStore for preferences. | -| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). | -| `core:domain` | Pure KMP business logic and UseCases. | -| `core:data` | Core manager implementations and data orchestration. | -| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). | -| `core:di` | Common DI qualifiers and dispatchers. | -| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies per feature domain (e.g., `SettingsRoute`, `NodesRoute`). `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence — new routes are registered at compile time. | -| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | -| `core:service` | KMP service layer; Android bindings stay in `androidMain`. | -| `core:api` | Public AIDL/API integration module for external clients. | -| `core:prefs` | KMP preferences layer built on DataStore abstractions. | -| `core:barcode` | Barcode scanning (Android-only). | -| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. | -| `core/ble/` | Bluetooth Low Energy stack using Kable. | -| `core/resources/` | Centralized string and image resources (Compose Multiplatform). | -| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. | -| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Scans for provisioning devices, lists available networks, applies credentials. Uses `core:ble` Kable abstractions. | -| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi via Kable/Ktor), native Nordic Secure DFU protocol (pure KMP, no Nordic library), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | -| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. Versioning mirrors Android via `config.properties` + `GitVersionValueSource`; a `generateDesktopBuildConfig` task produces `DesktopBuildConfig.kt` at build time. | -| `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app showing `core:api` service integration. Do not add code here. See `core/api/README.md` for the current integration guide. | +Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md` as needed. + -## 3. Development Guidelines & Coding Standards - -### A. UI Development (Jetpack Compose) -- **Material 3:** The app uses Material 3. -- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. -- **String formatting:** CMP's `stringResource(res, args)` / `getString(res, args)` only support `%N$s` (string) and `%N$d` (integer) positional specifiers. Float formats like `%N$.1f` are NOT supported — they pass through unsubstituted. For float values, pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass the result as a `%N$s` string arg. Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings, since CMP does not convert `%%` to `%`. For JVM-only code using `formatString()` (which wraps `String.format()`), full printf specifiers including `%N$.Nf` and `%%` are supported. -- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). -- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable. -- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. -- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. -- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes, `extraPane()`, and draggable dividers (`VerticalDragHandle` + `paneExpansionState`) for widths ≥ 1200dp. -- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. - -### B. Logic & Data Layer -- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module. -- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives: - - `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`. - - `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`. - - `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`. - - `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). Note: JetBrains now recommends `kotlinx-io` as the official Kotlin I/O library (built on Okio). This project standardizes on Okio directly; do not migrate without explicit decision. - - `kotlinx.coroutines.Dispatchers.IO` → `org.meshtastic.core.common.util.ioDispatcher` (expect/actual). Note: `Dispatchers.IO` is available in `commonMain` since kotlinx.coroutines 1.8.0, but this project uses the `ioDispatcher` wrapper for consistency. -- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`. -- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`. -- **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`. -- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model. -- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient. -- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(backStack: NavBackStack)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider` block. Do NOT define navigation graphs in platform-specific source sets. -- **Concurrency:** Use Kotlin Coroutines and Flow. -- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`. -- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry` blocks are scoped to the entry's backstack lifetime and cleared on pop. -- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves. -- **BLE:** All Bluetooth communication must route through `core:ble` using Kable. -- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`. -- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules. -- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. -- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane properties (Android) or Gradle CLI (desktop) to enable remote license/funding fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone — that burns API calls on every PR check. -- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. -- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main. -- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. -- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code. -- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes. -- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. - -### C. Namespacing -- **Standard:** Use the `org.meshtastic.*` namespace for all code. -- **Legacy:** Maintain the `com.geeksville.mesh` Application ID. - -## 4. Execution Protocol - -### A. Environment Setup -1. **JDK 21 MUST be used** to prevent Gradle sync/build failures. -2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`: - ```properties - MAPS_API_KEY=dummy_key - datadogApplicationId=dummy_id - datadogClientToken=dummy_token - ``` - -### B. Strict Execution Commands -Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues. - -**Baseline (recommended order):** -```bash -./gradlew clean -./gradlew spotlessCheck -./gradlew spotlessApply -./gradlew detekt -./gradlew assembleDebug -./gradlew test allTests -``` - -**Testing:** -```bash -# Full host-side unit test run (required — see note below): -./gradlew test allTests - -# Pure-Android / pure-JVM modules only (app, desktop, core:api, core:barcode, feature:widget, mesh_service_example): -./gradlew test - -# KMP modules only (all core:* KMP + all feature:* KMP modules — jvmTest + testAndroidHostTest + iosSimulatorArm64Test): -./gradlew allTests - -# CI-aligned flavor-explicit Android unit tests: -./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest - -./gradlew connectedAndroidTest # Run instrumented tests -./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests -./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks -``` - -> **Why `test allTests` and not just `test`:** -> In KMP modules, the `test` task name is **ambiguous** — Gradle matches both `testAndroid` and -> `testAndroidHostTest` and refuses to run either, silently skipping all 25 KMP modules. -> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP Gradle plugin for each -> KMP module. It runs `jvmTest`, `testAndroidHostTest` (where declared with `withHostTest {}`), and -> `iosSimulatorArm64Test` (disabled at execution — iOS targets are compile-only). Conversely, -> `allTests` does **not** cover the pure-Android modules (`:app`, `:core:api`, `:core:barcode`, -> `:feature:widget`, `:mesh_service_example`, `:desktop`), which is why both are needed. - -*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.* - -**CI workflow conventions (GitHub Actions):** -- Reusable CI in `.github/workflows/reusable-check.yml` is structured as four parallel job groups: - 1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation. Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs. - 2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`): - - `shard-core`: `allTests` for all `core:*` KMP modules. - - `shard-feature`: `allTests` for all `feature:*` KMP modules. - - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`). - Each shard generates its own Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. - Downstream jobs (test-shards, android-check, build-desktop) use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. - 3. **`android-check`** — Builds APKs and runs instrumented tests (depends on `lint-check`). - 4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds runnable desktop distributions via `createDistributable` (depends on `lint-check`). The Kotlin/Native host-platform warning on `linux-aarch64` is non-fatal; only JVM targets are compiled for desktop. -- Test sharding uses `fail-fast: false` so a failure in one shard does not cancel the others. -- JUnit Platform parallel execution is enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races (JVM-global singleton used by 19+ ViewModel test classes). Cross-module parallelism comes from Gradle forks (`maxParallelForks`). -- `test-retry` plugin (maxRetries=2, maxFailures=10) is applied to all module types: `AndroidApplicationConventionPlugin`, `AndroidLibraryConventionPlugin`, and `KmpLibraryConventionPlugin`. -- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API. -- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`. -- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch). -- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI. -- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes. -- **Runner strategy (three tiers):** - - **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). These run only shell scripts or GitHub API calls and benefit from ARM runners' shorter queue times. - - **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin where possible for reproducibility. - - **Desktop runners:** Reusable CI uses a multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`. -- **CI Gradle properties:** `gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties` before any Gradle invocation. Key CI overrides: `org.gradle.daemon=false` (single-use runners), `kotlin.incremental=false` (fresh checkouts), `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon, VFS watching disabled, workers capped at 4, `org.gradle.isolated-projects=true` for better parallelism. Disables unused Android build features (`resvalues`, `shaders`). This follows the nowinandroid `ci-gradle.properties` pattern. -- **CI optimization strategies (2026):** Applied comprehensive CI optimizations (P0-P3): - - **P0 (merged Gradle invocations):** `lint-check` merges spotlessCheck, detekt, android lint, and kmpSmokeCompile into a single Gradle invocation to avoid 3x cold-start overhead. Uses `filter: 'blob:none'` for blobless git clone. Switches submodules from `'recursive'` to boolean (saves overhead on nested submodule discovery). - - **P1 (reduced PR overhead):** Added `run_coverage` workflow input (default: true); PRs skip Kover reports via conditional tasks in test-shards matrix. Increased `maxParallelForks` in CI to use all available processors (4 on standard runners) when `ci=true` property is set, vs. half locally for system responsiveness. - - **P2 (build feature optimization):** Detekt disables non-essential report formats in CI (html, txt, md); retains only xml + sarif for GitHub annotations. Disables unused Android build features (resvalues, shaders) in `ci-gradle.properties`. - - **P3 (structural improvement):** Removed `verify-check-changes-filter` from `validate-and-build` dependencies; it now runs in parallel as a standalone required check instead of gating the main build. -- **`maxParallelForks` CI logic:** ProjectExtensions.kt line ~79 checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true` to enable this. -- **Detekt report formats:** Detekt.kt line ~44 checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are required for GitHub reporting. -- **KMP Smoke Compile:** Use `./gradlew kmpSmokeCompile` instead of listing individual module compile tasks. The `kmpSmokeCompile` lifecycle task (registered in `RootConventionPlugin`) auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks. -- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` failures when Robolectric downloads instrumented SDK jars. The cache key is `robolectric-{version}-sdk{level}` — update it when bumping the Robolectric version in `libs.versions.toml` or the SDK level in `robolectric.properties` / `@Config(sdk = ...)`. -- **`mavenLocal()` gated:** The `mavenLocal()` repository is disabled by default to prevent CI cache poisoning. For local JitPack testing, pass `-PuseMavenLocal` to Gradle. -- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent the agent from getting stuck in an interactive prompt. -- **Text Search:** Prefer using `rg` (ripgrep) over `grep` or `find` for fast text searching across the codebase. - -### C. Documentation Sync -`AGENTS.md` is the single source of truth for agent instructions. `.github/copilot-instructions.md` and `GEMINI.md` are thin stubs that redirect here — do NOT duplicate content into them. - -When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md` as needed. - -## 5. Troubleshooting -- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts. -- **Missing Secrets:** Check `local.properties`. -- **JDK Version:** JDK 21 is required. -- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist. -- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`). \ No newline at end of file + +- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application. +- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. +- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety. +- **CMP Over Android:** Use `compose-multiplatform` constraints (e.g., no float formatting in `stringResource`). +- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..39958ecd0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# Meshtastic Android - Claude Code Guide + +@AGENTS.md + +## Claude-Specific Instructions + +- **Think First:** Always outline your step-by-step reasoning inside `` tags before writing code or shell commands. Claude models perform significantly better on complex KMP tasks when they "think out loud" first. +- **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task. +- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). diff --git a/GEMINI.md b/GEMINI.md index 9076b718e..72a350afb 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,6 +1,6 @@ -# Meshtastic Android - Agent Guide +# Meshtastic Android - Google Gemini Guide -**Canonical instructions live in [`AGENTS.md`](AGENTS.md).** This file exists as `GEMINI.md` so Google Gemini discovers it automatically. +> **Note:** The canonical instructions for all AI Agents have been deduplicated. -See [AGENTS.md](AGENTS.md) for architecture, conventions, execution protocol, and coding standards. -See [docs/agent-playbooks/README.md](docs/agent-playbooks/README.md) for version baselines and task recipes. +You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. +After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks. diff --git a/SOUL.md b/SOUL.md index 793387334..45924b40f 100644 --- a/SOUL.md +++ b/SOUL.md @@ -26,6 +26,6 @@ I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-An I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers. For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth. -For implementation recipes and verification scope, I use `docs/agent-playbooks/README.md`. +For implementation recipes and verification scope, I use `.skills/` directory. diff --git a/docs/agent-playbooks/README.md b/docs/agent-playbooks/README.md deleted file mode 100644 index 5d25a5509..000000000 --- a/docs/agent-playbooks/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Agent Playbooks - -These playbooks are execution-focused guidance for common changes in this repository. - -Use `AGENTS.md` as the source of truth for architecture boundaries and required conventions. If guidance conflicts, follow `AGENTS.md` and current code patterns. - -## Version baseline for external docs - -When checking upstream docs/examples, match these repository-pinned versions from `gradle/libs.versions.toml`: - -- Kotlin: `2.3.20` -- Koin: `4.2.0` (`koin-annotations` `4.2.0` — uses same version as `koin-core`; compiler plugin `0.4.1`) -- JetBrains Navigation 3: `1.1.0-beta01` (`org.jetbrains.androidx.navigation3`) -- JetBrains Lifecycle (multiplatform): `2.11.0-alpha02` (`org.jetbrains.androidx.lifecycle`) -- AndroidX Lifecycle (Android-only): `2.10.0` (`androidx.lifecycle`) -- Kotlin Coroutines: `1.10.2` -- Compose Multiplatform: `1.11.0-beta01` -- JetBrains Material 3 Adaptive: `1.3.0-alpha06` (`org.jetbrains.compose.material3.adaptive`) - -Prefer versioned docs pages that match those versions (for example, Koin `4.2` docs rather than older `4.0/4.1` pages). - -## Dependency alias quick-reference - -Version catalog aliases split cleanly by fork provenance. **Use the right prefix for the right source set.** - -| Alias prefix | Coordinates | Use in | -|---|---|---| -| `jetbrains-lifecycle-*` | `org.jetbrains.androidx.lifecycle:*` | `commonMain`, `androidMain` | -| `jetbrains-navigation3-ui` | `org.jetbrains.androidx.navigation3:navigation3-ui` | `commonMain`, `androidMain` | -| `jetbrains-navigationevent-*` | `org.jetbrains.androidx.navigationevent:*` | `commonMain`, `androidMain` | -| `jetbrains-compose-material3-adaptive-*` | `org.jetbrains.compose.material3.adaptive:*` | `commonMain`, `androidMain` | -| `androidx-lifecycle-process` | `androidx.lifecycle:lifecycle-process` | `androidMain` only — `ProcessLifecycleOwner` | -| `androidx-lifecycle-testing` | `androidx.lifecycle:lifecycle-runtime-testing` | `androidUnitTest` only | - -> **Note:** JetBrains does not publish a separate `navigation3-runtime` artifact — `navigation3-ui` is the only artifact. The version catalog only defines `jetbrains-navigation3-ui`. The `lifecycle-runtime-ktx` and `lifecycle-viewmodel-ktx` KTX aliases were removed (extensions merged into base artifacts since Lifecycle 2.8.0). - -Quick references: - -- Koin annotations (4.2 docs): `https://insert-koin.io/docs/reference/koin-annotations/start` -- Koin KMP docs: `https://insert-koin.io/docs/reference/koin-annotations/kmp` -- AndroidX Navigation 3 release notes: `https://developer.android.com/jetpack/androidx/releases/navigation3` -- Kotlin release notes: `https://kotlinlang.org/docs/releases.html` - -## Playbooks - -- `docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md` - DI and Navigation 3 mistakes to avoid. -- `docs/agent-playbooks/kmp-source-set-bridging-playbook.md` - when to use `expect`/`actual` vs interfaces + app wiring. -- `docs/agent-playbooks/task-playbooks.md` - step-by-step recipes for common implementation tasks, plus code anchor quick reference. -- `docs/agent-playbooks/testing-and-ci-playbook.md` - which Gradle tasks to run based on change type, plus CI parity. - - - diff --git a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md deleted file mode 100644 index 550fd2079..000000000 --- a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md +++ /dev/null @@ -1,58 +0,0 @@ -# DI and Navigation 3 Anti-Patterns Playbook - -This playbook is a fast guardrail for high-risk mistakes in dependency injection and navigation. - -Version note: align guidance with repository-pinned versions in `gradle/libs.versions.toml` (currently Koin `4.2.x` and Navigation 3 JetBrains fork `1.1.x`). - -## DI anti-patterns - -- Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. -- Do use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules. This provides compile-time safety and encapsulates dependency graphs per feature, which is the recommended 2026 KMP practice for Koin 4.x. -- Don't instantiate ViewModels or service dependencies manually in Compose or activities. -- Do resolve app-layer wrappers via Koin (`koinViewModel()` / injected bindings). -- Don't spread DI graph setup across unrelated modules without registration in app startup. -- Do ensure modules are reachable from app bootstrap in `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`. -- Don't assume feature/core `@Module` classes are active automatically. -- Do ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`. -- **Don't use Koin K2 Compiler Plugin's A1 Module Compile Safety checks for inverted dependencies.** -- **Do** leave A1 `compileSafety` disabled in `build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt` (uses typed `KoinGradleExtension`). We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) to handle our decoupled Clean Architecture design where interfaces are declared in one module and implemented in another. -- **Don't** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values. - -### Current code anchors (DI) - -- App-level module scanning: `app/src/main/kotlin/org/meshtastic/app/MainKoinModule.kt` -- App startup + Koin init: `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` -- Shared ViewModel base: `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` -- Shared base UI ViewModel: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt` - -## Navigation 3 anti-patterns - -- Don't reintroduce controller-coupled navigation APIs for shared flow state. -- Do use Navigation 3 types (`NavKey`, `NavBackStack`, `EntryProviderScope`) consistently. -- Don't build route identifiers as ad-hoc strings in feature code when typed route keys already exist. -- Do keep route definitions in `core:navigation` and use typed route objects. -- Don't mutate back navigation with custom stacks disconnected from app backstack. -- Do mutate `NavBackStack` with `add(...)` and `removeLastOrNull()`. -- Don't use Android's `androidx.activity.compose.BackHandler` or custom `PredictiveBackHandler` in multiplatform UI. -- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures. -- Don't parse deep links manually in platform code or push single routes without a backstack. -- Do use `DeepLinkRouter.route()` in `core:navigation` to synthesize the correct typed backstack from RESTful paths. -- **Don't use a single `NavBackStack` list for multiple tabs, nor reuse the same `NavEntryDecorator` instances across different backstacks.** -- **Do** use `MultiBackstack` (from `core:navigation`) to manage independent `NavBackStack` instances per tab. When rendering the active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance. Failure to swap decorators when swapping backstacks causes Navigation 3 to perceive the inactive entries as "popped", permanently destroying their `ViewModelStore` and saved UI state. - -### Current code anchors (Navigation 3) - -- Typed routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` -- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt` -- App root backstack + `MeshtasticNavDisplay`: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt` -- Shared graph entry provider pattern: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` -- Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` - - -## Quick pre-PR checks for DI/navigation edits - -- Verify affected graph/module is registered and reachable from app startup. -- Verify no new Android framework type leaks into `commonMain`. -- Verify routes/backstack use typed keys and Navigation 3 primitives. -- Run targeted verification from `docs/agent-playbooks/testing-and-ci-playbook.md`. diff --git a/docs/agent-playbooks/kmp-source-set-bridging-playbook.md b/docs/agent-playbooks/kmp-source-set-bridging-playbook.md deleted file mode 100644 index 62753020a..000000000 --- a/docs/agent-playbooks/kmp-source-set-bridging-playbook.md +++ /dev/null @@ -1,45 +0,0 @@ -# KMP Source-Set Bridging Playbook - -Use this playbook when introducing platform-specific behavior into shared modules. - -## 1) Decide if `expect`/`actual` is needed - -Use `expect`/`actual` only when a platform API cannot be abstracted cleanly behind an interface passed from app wiring. - -- Prefer interface + DI when behavior is already app-owned. -- Prefer `expect`/`actual` for small platform primitives and utilities. - -Examples in current code: -- `core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt` -- `core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt` -- `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocationRepository.kt` - -## 2) Keep source-set boundaries strict - -- `commonMain`: business logic, shared models, coroutine/Flow orchestration. -- `androidMain`: Android framework integration (`Context`, system services, Android SDK). -- `app`: app bootstrap, DI root inclusion, Activity/service wiring, flavor-specific providers. - -## 3) Resource and UI bridging rules - -- Shared strings/resources must come from `core:resources`. -- Platform/flavor UI implementations should be injected via `CompositionLocal` from app. - -Examples: -- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` -- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` -- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` -- Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` - -## 4) DI and module activation checks - -- If a new feature/core module adds Koin annotations, verify it is included by app root module includes. -- App root includes are defined in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`. - -## 5) Verification checklist - -- No Android-only imports in `commonMain`. -- `expect`/`actual` declarations compile across relevant source sets. -- Routing/DI still resolves from app startup (`MeshUtilApplication`). -- Run verification tasks from `docs/agent-playbooks/testing-and-ci-playbook.md` appropriate to touched modules. - diff --git a/docs/agent-playbooks/task-playbooks.md b/docs/agent-playbooks/task-playbooks.md deleted file mode 100644 index 25a856d9f..000000000 --- a/docs/agent-playbooks/task-playbooks.md +++ /dev/null @@ -1,113 +0,0 @@ -# Task Playbooks - -Use these as practical recipes. Keep edits minimal and aligned with existing module boundaries. - -For architecture rules and coding standards, see [`AGENTS.md`](../../AGENTS.md). - -## Code Anchor Quick Reference - -Key files for discovering established patterns: - -| Pattern | Reference File | -|---|---| -| App DI wiring | `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` | -| App startup / Koin bootstrap | `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` | -| Shared ViewModel | `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` | -| `CompositionLocal` platform injection | `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` | -| Platform abstraction contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` | -| Node track map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` | -| Traceroute map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` | -| Shared strings resource | `core/resources/src/commonMain/composeResources/values/strings.xml` | -| Okio shared I/O | `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt` | -| `stateInWhileSubscribed` | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt` | - -## Playbook A: Add or update a user-visible string - -1. Add/update key in `core/resources/src/commonMain/composeResources/values/strings.xml`. -2. Import generated resource symbol in UI code (`org.meshtastic.core.resources.`). -3. Use `stringResource(Res.string.)` in Compose. -4. If the string appears in a shared dialog, prefer `core:ui` dialog components. -5. Verify no hardcoded user-facing strings were introduced. - -Reference examples: -- `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt` -- `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt` - -## Playbook B: Add shared ViewModel logic in a feature module - -1. Implement or extend base ViewModel logic in `feature//src/commonMain/...`. -2. Keep shared class free of Android framework dependencies. -3. Keep Android framework dependencies out of shared logic; if the module already uses Koin annotations in `commonMain`, keep patterns consistent and ensure app root inclusion. -4. Update navigation entry points in `feature/*/src/androidMain/kotlin/org/meshtastic/feature/*/navigation/...` to resolve ViewModels with `koinViewModel()`. - -Reference examples: -- Shared base: `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` -- Shared base UI ViewModel: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt` -- Navigation usage: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` -- Desktop navigation usage: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` - -## Playbook C: Add a new dependency or service binding - -1. Check `gradle/libs.versions.toml` for existing library and version alias. -2. Add new dependency to version catalog first (if truly new). -3. Wire implementation in the owning module (`core:*`, `feature:*`, or `app`) following existing architecture. -4. Register bindings/modules in app Koin graph where needed. -5. For Android system integration (WorkManager, service bootstrapping), wire via `MeshUtilApplication` and app-layer modules. - -Reference examples: -- App startup and Koin bootstrap: `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` -- App module scan: `app/src/main/kotlin/org/meshtastic/app/MainKoinModule.kt` - -## Playbook D: Add or modify navigation flow - -1. Define/extend route keys in `core:navigation`. -2. Implement feature entry/content using Navigation 3 types (`NavKey`, `NavBackStack`, `EntryProviderScope`). -3. Add graph entries under the relevant feature module's `navigation` package (e.g., `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation`). -4. If the entry content depends on platform-specific UI (e.g. Activity context or specific desktop wrappers), use `expect`/`actual` declarations for the content composables. -5. Use backstack mutation (`add`, `removeLastOrNull`) instead of introducing controller-coupled APIs. -6. Verify deep-link behavior if route is externally reachable. - -Reference examples: -- Shared graph wiring: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` -- Android specific content: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt` -- Desktop specific content: `feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt` -- Feature intro graph pattern: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt` -- Desktop nav shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` - - -## Playbook E: Add flavor/platform-specific UI implementation - -1. Keep shared contracts in `core:ui` or feature shared code. -2. Inject flavor/platform implementation via `CompositionLocal` from `app`. -3. Avoid direct dependency from shared modules to Google Maps/osmdroid/other Android SDK-only APIs. -4. Keep adapter types narrow and stable (interfaces, DTO-like params). - -Reference examples: -- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` -- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` -- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` -- Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` -- Consumer side: `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt` - -## Playbook F: Onboard a new platform target - -1. Create a platform application module (e.g., `desktop/`, `ios/`). -2. Copy `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt` as the starting stub set. All repository interfaces have no-op implementations there. -3. Create a `KoinModule` that mirrors `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` — use stubs for unimplemented interfaces, real implementations where available. -4. Add `kotlinx-coroutines-swing` (JVM/Desktop) or the equivalent platform coroutines dispatcher module. Without it, `Dispatchers.Main` is unavailable and any code using `lifecycle.coroutineScope` will crash at runtime. -5. Progressively replace stubs with real implementations (e.g., serial transport for desktop, CoreBluetooth for iOS). -6. Add `()` target to feature modules as needed (all `core:*` modules already declare `jvm()`). -7. Update CI JVM smoke compile step in `.github/workflows/reusable-check.yml` to include new modules. -8. If `commonMain` code fails to compile for the new target, it's a KMP migration debt — fix the shared code, not the target. - -Reference examples: -- Desktop stubs: `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt` -- Desktop DI: `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` -- Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` -- Desktop shared feature wiring: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` -- Desktop-specific screen: `feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt` -- Roadmap: `docs/roadmap.md` - - diff --git a/docs/agent-playbooks/testing-and-ci-playbook.md b/docs/agent-playbooks/testing-and-ci-playbook.md deleted file mode 100644 index a7f0796df..000000000 --- a/docs/agent-playbooks/testing-and-ci-playbook.md +++ /dev/null @@ -1,88 +0,0 @@ -# Testing and CI Playbook - -Use this matrix to choose the right verification depth for a change. - -## 1) Baseline local verification order - -Run in this order for routine changes: - -```bash -./gradlew clean -./gradlew spotlessCheck -./gradlew spotlessApply -./gradlew detekt -./gradlew assembleDebug -./gradlew test -``` - -Notes: -- This order aligns with repository guidance in `AGENTS.md` and `.github/copilot-instructions.md`. -- CI runs host verification and Android build/device verification in separate jobs inside `.github/workflows/reusable-check.yml`. - -## 2) Change-type matrix - -- `docs-only` changes: - - Usually no Gradle run required. - - If you touched code examples or command docs, at least run `spotlessCheck` if practical. - - If you changed architecture, CI, validation commands, or agent workflow guidance, update the mirrored docs in `AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, and `docs/kmp-status.md` in the same slice. -- `UI text/resource` changes: - - `spotlessCheck`, `detekt`, `assembleDebug`. -- `feature/commonMain logic` changes: - - `spotlessCheck`, `detekt`, `test`, `assembleDebug`. -- `navigation/DI wiring` changes (app graph, Koin module/wrapper changes): - - `spotlessCheck`, `detekt`, `assembleDebug`, `test`, plus `testFdroidDebugUnitTest` and `testGoogleDebugUnitTest` when available locally. - - If touching any KMP module, also run `kmpSmokeCompile`. -- `worker/service/background` changes: - - `spotlessCheck`, `detekt`, `assembleDebug`, `test`, and targeted tests around WorkManager/service behavior. -- `BLE/networking/core repository` changes: - - `spotlessCheck`, `detekt`, `assembleDebug`, `test`. - -## 3) Flavor and instrumentation checks - -Run these when relevant to map/provider/flavor-specific behavior: - -```bash -./gradlew lintFdroidDebug lintGoogleDebug -./gradlew testFdroidDebug -./gradlew testGoogleDebug -./gradlew connectedAndroidTest -``` - -## 4) CI parity checks - -Current reusable check workflow includes: - -- `spotlessCheck detekt` -- Android lint for all directly runnable Android modules: - `app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug` - *(Note: `mesh_service_example:lintDebug` is temporary — the module is deprecated and will be - removed along with its CI tasks in a future release.)* -- Host tests plus coverage aggregation: - `test koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug core:api:koverXmlReportDebug core:barcode:koverXmlReportFdroidDebug core:barcode:koverXmlReportGoogleDebug mesh_service_example:koverXmlReportDebug desktop:koverXmlReport` - *(Note: `mesh_service_example:koverXmlReportDebug` is temporary — see above.)* -- KMP smoke compile lifecycle task (auto-discovers KMP modules and runs JVM + iOS simulator compile checks): - `kmpSmokeCompile` -- Android build tasks: - `app:assembleFdroidDebug app:assembleGoogleDebug mesh_service_example:assembleDebug` - *(Note: `mesh_service_example:assembleDebug` is temporary — see above.)* -- Instrumented tests (when emulator tests are enabled): - `app:connectedFdroidDebugAndroidTest app:connectedGoogleDebugAndroidTest core:barcode:connectedFdroidDebugAndroidTest core:barcode:connectedGoogleDebugAndroidTest` -- Coverage uploads happen once from the host job; instrumented test results upload once from the first Android matrix API to avoid duplicate reporting. - -Reference: `.github/workflows/reusable-check.yml` - -PR workflow note: - -- `.github/workflows/pull-request.yml` ignores docs-only changes (`**/*.md`, `docs/**`), so doc-only PRs may skip Android CI by design. -- PR change detection includes workflow/build/config paths such as `.github/workflows/**`, `desktop/**`, `mesh_service_example/**` (deprecated, will be removed), `config/**`, `gradle/**`, `settings.gradle.kts`, and `test.gradle.kts`. -- Android CI on PRs runs with `run_instrumented_tests: false`; merge queue keeps the full emulator matrix on API 26 and 35. -- Gradle cache writes are enabled for trusted refs/events (`main`, `merge_group`, and `gh-readonly-queue/*`); other refs run in read-only cache mode. - -## 5) Practical guidance for agents - -- Start with the smallest set that validates your touched area. -- Keep documentation continuously in sync with architecture, CI, and workflow changes; do not defer doc fixes to a later PR. -- If modifying cross-module contracts (routes, repository interfaces, DI graph), run the broader baseline. -- If unable to run full validation locally, report exactly what ran and what remains. - - diff --git a/docs/kmp-status.md b/docs/kmp-status.md index fb9d74175..1f8ce1062 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -176,5 +176,5 @@ Remaining to be extracted from `:app` or unified in `commonMain`: - Roadmap: [`docs/roadmap.md`](./roadmap.md) - Agent guide: [`AGENTS.md`](../AGENTS.md) -- Playbooks: [`docs/agent-playbooks/`](./agent-playbooks/) +- Agent skills: [`.skills/`](../.skills/) - Decision records: [`docs/decisions/`](./decisions/) From bc44af1597d9d42492e89bfdc55fd4de1c0f06f0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:29:25 -0500 Subject: [PATCH 026/114] fix(connections): show device name during connecting state (#5085) --- .../feature/connections/ScannerViewModel.kt | 2 ++ .../ui/components/ConnectingDeviceInfo.kt | 13 +++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index d094aa170..8ed5619cd 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -206,6 +206,7 @@ open class ScannerViewModel( changeDeviceAddress(it.fullAddress) true } else { + radioPrefs.setDevName(it.name) requestBonding(it) false } @@ -216,6 +217,7 @@ open class ScannerViewModel( changeDeviceAddress(it.fullAddress) true } else { + radioPrefs.setDevName(it.name) requestPermission(it) false } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt index ebc981398..0d079ebdc 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt @@ -26,13 +26,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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.graphics.RectangleShape import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState @@ -79,20 +79,17 @@ fun ConnectingDeviceInfo( } } - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - val largeHeight = ButtonDefaults.LargeContainerHeight - @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( - onClick = onClickDisconnect, - shapes = ButtonDefaults.shapesFor(largeHeight), - modifier = Modifier.fillMaxWidth().height(largeHeight), + shape = RectangleShape, + modifier = Modifier.fillMaxWidth().height(40.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.StatusRed, contentColor = Color.White, ), + onClick = onClickDisconnect, ) { - Text(stringResource(Res.string.disconnect), style = ButtonDefaults.textStyleFor(largeHeight)) + Text(stringResource(Res.string.disconnect)) } } } From ade314d503a4faf91f4566787d44ad515f45f1c5 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:42:58 -0500 Subject: [PATCH 027/114] build: upgrade TARGET_SDK to 37 and update AGP to 9.2.0-alpha08 (#5089) --- config.properties | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config.properties b/config.properties index 1bb8534cd..de820bc85 100644 --- a/config.properties +++ b/config.properties @@ -21,8 +21,8 @@ VERSION_CODE_OFFSET=29314197 # Application and SDK versions APPLICATION_ID=com.geeksville.mesh MIN_SDK=26 -TARGET_SDK=36 -COMPILE_SDK=36 +TARGET_SDK=37 +COMPILE_SDK=37 # Base version name for local development and fallback # On CI, this is overridden by the Git tag diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cd010b7f..e1c5630ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ xmlutil = "0.91.3" # Android -agp = "9.1.0" +agp = "9.2.0-alpha08" appcompat = "1.7.1" accompanist = "0.37.3" From c059f19cc66eb502157e8f5b0756c69e79416083 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:59:21 -0500 Subject: [PATCH 028/114] =?UTF-8?q?ci:=20reduce=20CI=20costs=20by=20~54%?= =?UTF-8?q?=20=E2=80=94=20skip=20desktop=20builds=20in=20PR/main,=20reduce?= =?UTF-8?q?=20scheduled=20frequency=20(#5090)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs.yml | 16 +++++++++++++--- .github/workflows/main-check.yml | 7 ++++--- .github/workflows/pull-request.yml | 5 ++++- .github/workflows/reusable-check.yml | 4 ++++ .github/workflows/scheduled-updates.yml | 4 ++-- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 568da41f4..faa9ff3c3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,16 @@ on: push: branches: - main + paths: + # Only rebuild docs when source code changes (Dokka generates from KDoc) + - 'app/src/**' + - 'core/**/src/**' + - 'feature/**/src/**' + - 'desktop/src/**' + - 'build-logic/**' + - 'build.gradle.kts' + - 'settings.gradle.kts' + - '.github/workflows/docs.yml' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -29,11 +39,11 @@ permissions: pages: write id-token: write -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +# Allow only one concurrent deployment; cancel queued runs since only the latest +# main state matters for documentation. concurrency: group: "pages" - cancel-in-progress: false + cancel-in-progress: true jobs: build-docs: diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml index 4c29847a3..4ef967dfc 100644 --- a/.github/workflows/main-check.yml +++ b/.github/workflows/main-check.yml @@ -20,8 +20,9 @@ jobs: uses: ./.github/workflows/reusable-check.yml with: run_lint: true - run_unit_tests: true - run_instrumented_tests: true - api_levels: '[35]' # One API level is enough for post-merge sanity check + run_unit_tests: false + run_instrumented_tests: false + run_desktop_builds: false + api_levels: '[35]' upload_artifacts: true secrets: inherit diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0d2b67b36..7c2ea7f50 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -99,7 +99,9 @@ jobs: PY # 2. VALIDATION & BUILD: Delegate to reusable-check.yml - # We disable instrumented tests and coverage for PRs to keep feedback fast (< 10 mins). + # We disable instrumented tests, coverage, and desktop builds for PRs to keep + # feedback fast (< 10 mins). Desktop compilation is already covered by the + # :desktop:test task in the shard-app test shard. validate-and-build: needs: check-changes if: needs.check-changes.outputs.android == 'true' @@ -109,6 +111,7 @@ jobs: run_unit_tests: true run_instrumented_tests: false run_coverage: false + run_desktop_builds: false api_levels: '[35]' upload_artifacts: true secrets: inherit diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index c67cc280a..8e310e9ac 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -18,6 +18,9 @@ on: api_levels: type: string default: '[35]' + run_desktop_builds: + type: boolean + default: true upload_artifacts: type: boolean default: true @@ -358,6 +361,7 @@ jobs: # ── Desktop Build ─────────────────────────────────────────────────── build-desktop: name: Build Desktop Debug (${{ matrix.os }}) + if: inputs.run_desktop_builds == true runs-on: ${{ matrix.os }} permissions: contents: read diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml index d516537e0..2399d1f88 100644 --- a/.github/workflows/scheduled-updates.yml +++ b/.github/workflows/scheduled-updates.yml @@ -2,8 +2,8 @@ name: Scheduled Updates (Firmware, Hardware, Translations) on: schedule: - - cron: '0 * * * *' # Run every hour - workflow_dispatch: # Allow manual triggering + - cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost) + workflow_dispatch: # Allow manual triggering jobs: update_assets: From 4156acf297795adcefb95900ea7528b3c66e554a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:18:02 -0500 Subject: [PATCH 029/114] ci: fix Gradle cache path validation warning for Robolectric jars (#5093) --- .github/actions/gradle-setup/action.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/actions/gradle-setup/action.yml b/.github/actions/gradle-setup/action.yml index 3753210b8..a42959190 100644 --- a/.github/actions/gradle-setup/action.yml +++ b/.github/actions/gradle-setup/action.yml @@ -27,19 +27,14 @@ runs: distribution: ${{ inputs.jdk_distribution }} token: ${{ github.token }} - # Robolectric downloads instrumented SDK jars from Maven Central at test time. - # Cache them to avoid flaky SocketException failures on CI runners. - # Update the key when bumping robolectric version in libs.versions.toml or sdk in robolectric.properties. - - name: Cache Robolectric SDK jars - uses: actions/cache@v5 - with: - path: ~/.m2/repository/org/robolectric - key: robolectric-4.16.1-sdk34 - - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ inputs.cache_read_only }} cache-encryption-key: ${{ inputs.gradle_encryption_key }} cache-cleanup: on-success - add-job-summary: always \ No newline at end of file + add-job-summary: always + gradle-home-cache-includes: | + caches + notifications + ~/.m2/repository/org/robolectric \ No newline at end of file From a11dee42a707b024033e16b802f014966aad0b89 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:20:00 -0500 Subject: [PATCH 030/114] test: migrate Compose UI tests from androidTest to commonTest (#5091) --- .github/workflows/codeql.yml | 108 ------------------ .github/workflows/main-check.yml | 2 - .github/workflows/merge-queue.yml | 2 - .github/workflows/pull-request.yml | 8 +- .github/workflows/reusable-check.yml | 96 +--------------- .skills/code-review/SKILL.md | 1 + .skills/testing-ci/SKILL.md | 7 +- app/build.gradle.kts | 6 - .../filter/MessageFilterIntegrationTest.kt | 48 -------- core/ble/build.gradle.kts | 7 -- core/ui/build.gradle.kts | 4 +- .../core/ui/component/AlertHostTest.kt | 34 ++++-- .../core/ui/component/ImportFabUiTest.kt | 56 +++++---- .../core/ui/util/AlertManagerUiTest.kt | 38 +++--- docs/decisions/architecture-review-2026-03.md | 18 +-- feature/firmware/build.gradle.kts | 8 -- feature/intro/build.gradle.kts | 7 -- feature/map/build.gradle.kts | 7 -- feature/messaging/build.gradle.kts | 5 +- .../messaging/component/MessageItemTest.kt | 34 +++--- feature/node/build.gradle.kts | 9 -- feature/settings/build.gradle.kts | 17 +-- .../component/MapReportingPreferenceTest.kt | 98 ---------------- .../settings/debugging/DebugSearchTest.kt | 76 ++++++------ .../component/EditDeviceProfileDialogTest.kt | 97 ++++++++-------- .../component/MapReportingPreferenceTest.kt | 99 ++++++++++++++++ gradle/libs.versions.toml | 1 + 27 files changed, 296 insertions(+), 597 deletions(-) delete mode 100644 .github/workflows/codeql.yml delete mode 100644 app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt (54%) rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt (63%) rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt (61%) rename feature/messaging/src/{androidTest => commonTest}/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt (81%) delete mode 100644 feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt rename feature/settings/src/{androidHostTest => commonTest}/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt (71%) rename feature/settings/src/{androidTest => commonTest}/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt (54%) create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index e67a217c7..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,108 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL Advanced" - -on: - # push: - # branches: [ "main" ] - # pull_request: - # branches: [ "main" ] - schedule: - - cron: '0 0 * * 0' - workflow_dispatch: - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-24.04' }} - if: github.repository == 'meshtastic/Meshtastic-Android' - permissions: - # required for all workflows - security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - include: - - language: actions - build-mode: none - - language: java-kotlin - build-mode: autobuild - # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 - - name: Java Setup - uses: actions/setup-java@v5 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' - token: ${{ github.token }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml index 4ef967dfc..eaf3f54d3 100644 --- a/.github/workflows/main-check.yml +++ b/.github/workflows/main-check.yml @@ -21,8 +21,6 @@ jobs: with: run_lint: true run_unit_tests: false - run_instrumented_tests: false run_desktop_builds: false - api_levels: '[35]' upload_artifacts: true secrets: inherit diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml index 2818ca939..44d31183d 100644 --- a/.github/workflows/merge-queue.yml +++ b/.github/workflows/merge-queue.yml @@ -18,8 +18,6 @@ jobs: with: run_lint: true run_unit_tests: true - run_instrumented_tests: true - api_levels: '[26, 35]' # Comprehensive testing for Merge Queue upload_artifacts: false secrets: inherit diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 7c2ea7f50..22a611576 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -99,9 +99,9 @@ jobs: PY # 2. VALIDATION & BUILD: Delegate to reusable-check.yml - # We disable instrumented tests, coverage, and desktop builds for PRs to keep - # feedback fast (< 10 mins). Desktop compilation is already covered by the - # :desktop:test task in the shard-app test shard. + # We disable coverage and desktop builds for PRs to keep feedback fast + # (< 10 mins). Desktop compilation is already covered by the :desktop:test + # task in the shard-app test shard. validate-and-build: needs: check-changes if: needs.check-changes.outputs.android == 'true' @@ -109,10 +109,8 @@ jobs: with: run_lint: true run_unit_tests: true - run_instrumented_tests: false run_coverage: false run_desktop_builds: false - api_levels: '[35]' upload_artifacts: true secrets: inherit diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 8e310e9ac..26dbe7685 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -9,15 +9,9 @@ on: run_unit_tests: type: boolean default: true - run_instrumented_tests: - type: boolean - default: true run_coverage: type: boolean default: true - api_levels: - type: string - default: '[35]' run_desktop_builds: type: boolean default: true @@ -238,7 +232,7 @@ jobs: **/build/test-results retention-days: 7 - # ── Android Build & Instrumented Tests ────────────────────────────── + # ── Android Build ──────────────────────────────────────────────────── android-check: runs-on: ubuntu-24.04 permissions: @@ -247,10 +241,6 @@ jobs: needs: lint-check env: VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} - strategy: - fail-fast: true - matrix: - api_level: ${{ fromJson(inputs.api_levels) }} steps: - name: Checkout code @@ -265,99 +255,25 @@ jobs: gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - - name: Determine matrix metadata - id: matrix_meta - shell: bash - run: | - first_api=$(python3 - <<'PY' - import json - print(json.loads('${{ inputs.api_levels }}')[0]) - PY - ) - - if [[ "${{ matrix.api_level }}" == "$first_api" ]]; then - echo "is_first_api=true" >> "$GITHUB_OUTPUT" - else - echo "is_first_api=false" >> "$GITHUB_OUTPUT" - fi - - - name: Determine Android tasks - id: tasks - shell: bash - run: | - tasks=( - "app:assembleFdroidDebug" - "app:assembleGoogleDebug" - ) - - if [[ "${{ inputs.run_instrumented_tests }}" == "true" ]]; then - tasks+=( - "app:connectedFdroidDebugAndroidTest" - "app:connectedGoogleDebugAndroidTest" - ) - fi - - printf 'tasks=%s\n' "${tasks[*]}" >> "$GITHUB_OUTPUT" - - - name: Enable KVM group perms - if: inputs.run_instrumented_tests == true - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Run Android Build & Instrumented Tests - if: inputs.run_instrumented_tests == true - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api_level }} - arch: x86_64 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan - - - name: Run Android Build - if: inputs.run_instrumented_tests == false - run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan - - - name: Upload instrumented test results to Codecov - if: ${{ !cancelled() && inputs.run_instrumented_tests && steps.matrix_meta.outputs.is_first_api == 'true' }} - uses: codecov/codecov-action@v6 - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: meshtastic/Meshtastic-Android - flags: android-instrumented - fail_ci_if_error: false - report_type: test_results - files: "**/build/outputs/androidTest-results/**/*.xml" + - name: Build Android APKs + run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan - name: Upload debug artifact - if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }} + if: ${{ inputs.upload_artifacts }} uses: actions/upload-artifact@v7 with: name: app-debug-apks path: app/build/outputs/apk/*/debug/*.apk - retention-days: 14 + retention-days: 7 - name: Report App Size - if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }} + if: always() run: | echo "### App Size Report" >> $GITHUB_STEP_SUMMARY echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY - - name: Upload Android reports - if: ${{ always() && inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 - with: - name: reports-android-api-${{ matrix.api_level }} - path: | - **/build/outputs/androidTest-results - retention-days: 7 - if-no-files-found: ignore - # ── Desktop Build ─────────────────────────────────────────────────── build-desktop: name: Build Desktop Debug (${{ matrix.os }}) diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md index 08caa95be..dce08761d 100644 --- a/.skills/code-review/SKILL.md +++ b/.skills/code-review/SKILL.md @@ -56,6 +56,7 @@ When reviewing code, meticulously verify the following categories. Flag any devi - [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules. ### 7. Testing +- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests. - [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`. - [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. - [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues. diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 8342714de..586c1ef9c 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -34,14 +34,13 @@ Run in this order for routine changes to ensure code formatting, analysis, and b - `worker/service/background` changes: Broad tests, targeted WorkManager checks. - `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`. -## 3) Flavor and instrumentation checks +## 3) Flavor checks Run these when relevant to map, provider, or flavor-specific behavior: ```bash ./gradlew lintFdroidDebug lintGoogleDebug ./gradlew testFdroidDebug testGoogleDebug -./gradlew connectedAndroidTest ``` ## 4) CI Pipeline Architecture @@ -55,12 +54,12 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`). Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. -3. **`android-check`** — Builds APKs and runs instrumented tests (depends on `lint-check`). +3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`). 4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`). ### Runner Strategy (Three Tiers) - **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times. -- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin for reproducibility. +- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, publish, dependency-submission). Pin for reproducibility. - **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging. ### CI Gradle Properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed9f3a766..1c8ed4c39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -297,12 +297,6 @@ dependencies { fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } fdroidImplementation(libs.osmbonuspack) - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.kotlinx.coroutines.test) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - androidTestImplementation(libs.koin.test) - testImplementation(kotlin("test-junit")) testImplementation(libs.androidx.work.testing) testImplementation(libs.koin.test) diff --git a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt deleted file mode 100644 index 4cbf88356..000000000 --- a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.filter - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.koin.test.KoinTest -import org.koin.test.inject -import org.meshtastic.core.repository.FilterPrefs -import org.meshtastic.core.repository.MessageFilter - -@RunWith(AndroidJUnit4::class) -class MessageFilterIntegrationTest : KoinTest { - - private val filterPrefs: FilterPrefs by inject() - - private val filterService: MessageFilter by inject() - - @org.junit.Ignore("Flaky integration test, needs Koin test rule setup") - @Test - fun filterPrefsIntegration() = runTest { - filterPrefs.setFilterEnabled(true) - filterPrefs.setFilterWords(setOf("test", "spam")) - // Wait briefly for DataStore to process the writes and flows to emit - kotlinx.coroutines.delay(100) - filterService.rebuildPatterns() - - assertTrue(filterService.shouldFilter("this is a test message")) - assertTrue(filterService.shouldFilter("spam content")) - } -} diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts index d26431634..f270e6aa3 100644 --- a/core/ble/build.gradle.kts +++ b/core/ble/build.gradle.kts @@ -50,12 +50,5 @@ kotlin { implementation(libs.kotlinx.coroutines.test) implementation(projects.core.testing) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.androidx.lifecycle.testing) - } - } } } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index bbe3204e5..76475e096 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -26,7 +26,6 @@ kotlin { android { namespace = "org.meshtastic.core.ui" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -70,8 +69,9 @@ kotlin { implementation(projects.core.testing) implementation(libs.junit) implementation(libs.kotlinx.coroutines.test) + implementation(libs.compose.multiplatform.ui.test) } - val androidHostTest by getting { dependencies { implementation(libs.androidx.test.runner) } } + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt similarity index 54% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt index b6abd64b0..ab0f1a80f 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt @@ -16,28 +16,46 @@ */ package org.meshtastic.core.ui.component +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText -import org.junit.Rule -import org.junit.Test +import androidx.compose.ui.test.runComposeUiTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain import org.meshtastic.core.ui.util.AlertManager +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) class AlertHostTest { - @get:Rule val composeTestRule = createComposeRule() + private val testDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } @Test - fun alertHost_showsDialog_whenAlertIsTriggered() { + fun alertHost_showsDialog_whenAlertIsTriggered() = runComposeUiTest { val alertManager = AlertManager() val title = "Alert Title" val message = "Alert Message" - composeTestRule.setContent { AlertHost(alertManager = alertManager) } + setContent { AlertHost(alertManager = alertManager) } alertManager.showAlert(title = title, message = message) - composeTestRule.onNodeWithText(title).assertIsDisplayed() - composeTestRule.onNodeWithText(message).assertIsDisplayed() + onNodeWithText(title).assertIsDisplayed() + onNodeWithText(message).assertIsDisplayed() } } diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt similarity index 63% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt index cc4f32b8e..650671de2 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt @@ -18,27 +18,25 @@ package org.meshtastic.core.ui.component import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.assertDoesNotExist +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test +import androidx.compose.ui.test.runComposeUiTest import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User +import kotlin.test.Test +@OptIn(ExperimentalTestApi::class) class ImportFabUiTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun importFab_expands_onButtonClick_whenSupported() { + fun importFab_expands_onButtonClick_whenSupported() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { CompositionLocalProvider( LocalBarcodeScannerSupported provides true, LocalNfcScannerSupported provides true, @@ -48,18 +46,18 @@ class ImportFabUiTest { } // Expand the FAB - composeTestRule.onNodeWithTag(testTag).performClick() + onNodeWithTag(testTag).performClick() // Verify menu items are visible using their tags - composeTestRule.onNodeWithTag("nfc_import").assertIsDisplayed() - composeTestRule.onNodeWithTag("qr_import").assertIsDisplayed() - composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() + onNodeWithTag("nfc_import").assertIsDisplayed() + onNodeWithTag("qr_import").assertIsDisplayed() + onNodeWithTag("url_import").assertIsDisplayed() } @Test - fun importFab_hidesNfcAndQr_whenNotSupported() { + fun importFab_hidesNfcAndQr_whenNotSupported() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { CompositionLocalProvider( LocalBarcodeScannerSupported provides false, LocalNfcScannerSupported provides false, @@ -69,41 +67,41 @@ class ImportFabUiTest { } // Expand the FAB - composeTestRule.onNodeWithTag(testTag).performClick() + onNodeWithTag(testTag).performClick() // Verify menu items are visible using their tags - composeTestRule.onNodeWithTag("nfc_import").assertDoesNotExist() - composeTestRule.onNodeWithTag("qr_import").assertDoesNotExist() - composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() + onNodeWithTag("nfc_import").assertDoesNotExist() + onNodeWithTag("qr_import").assertDoesNotExist() + onNodeWithTag("url_import").assertIsDisplayed() } @Test - fun importFab_showsUrlDialog_whenUrlItemClicked() { + fun importFab_showsUrlDialog_whenUrlItemClicked() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } + setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } - composeTestRule.onNodeWithTag(testTag).performClick() - composeTestRule.onNodeWithTag("url_import").performClick() + onNodeWithTag(testTag).performClick() + onNodeWithTag("url_import").performClick() // The URL dialog should be shown. // We'll search for its title indirectly or check if an AlertDialog appeared. } @Test - fun importFab_showsShareChannels_whenCallbackProvided() { + fun importFab_showsShareChannels_whenCallbackProvided() = runComposeUiTest { val testTag = "import_fab" - composeTestRule.setContent { + setContent { MeshtasticImportFAB(onImport = {}, onShareChannels = {}, isContactContext = false, testTag = testTag) } - composeTestRule.onNodeWithTag(testTag).performClick() - composeTestRule.onNodeWithTag("share_channels").assertIsDisplayed() + onNodeWithTag(testTag).performClick() + onNodeWithTag("share_channels").assertIsDisplayed() } @Test - fun importFab_showsSharedContactDialog_whenProvided() { + fun importFab_showsSharedContactDialog_whenProvided() = runComposeUiTest { val contact = SharedContact(user = User(long_name = "Suzume Goddess"), node_num = 1) - composeTestRule.setContent { + setContent { MeshtasticImportFAB( onImport = {}, sharedContact = contact, @@ -113,6 +111,6 @@ class ImportFabUiTest { } // Check if goddess is here - composeTestRule.onNodeWithText("Importing Suzume Goddess").assertIsDisplayed() + onNodeWithText("Importing Suzume Goddess").assertIsDisplayed() } } diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt similarity index 61% rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt index 5632d39c1..7d2e1d1a4 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt @@ -18,22 +18,21 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertTrue +@OptIn(ExperimentalTestApi::class) class AlertManagerUiTest { - @get:Rule val composeTestRule = createComposeRule() - - private val alertManager = AlertManager() - @Test - fun alertManager_showsAlert_whenRequested() { - composeTestRule.setContent { + fun alertManager_showsAlert_whenRequested() = runComposeUiTest { + val alertManager = AlertManager() + setContent { val alertData by alertManager.currentAlert.collectAsState() alertData?.let { data -> AlertPreviewRenderer(data) } } @@ -43,29 +42,24 @@ class AlertManagerUiTest { alertManager.showAlert(title = title, message = message) - composeTestRule.onNodeWithText(title).assertIsDisplayed() - composeTestRule.onNodeWithText(message).assertIsDisplayed() + onNodeWithText(title).assertIsDisplayed() + onNodeWithText(message).assertIsDisplayed() } @Test - fun alertManager_confirmButton_triggersCallbackAndDismisses() { + fun alertManager_confirmButton_triggersCallbackAndDismisses() = runComposeUiTest { + val alertManager = AlertManager() var confirmClicked = false - composeTestRule.setContent { + setContent { val alertData by alertManager.currentAlert.collectAsState() alertData?.let { data -> AlertPreviewRenderer(data) } } - alertManager.showAlert(title = "Confirm Title", onConfirm = { confirmClicked = true }) - - // Default confirm text is "Okay" from resources, but AlertPreviewRenderer uses it - // We'll search for the text "Okay" (assuming it matches the resource value) - // Since we are in a test, we might need to use a hardcoded string or a resource - // But for this test, let's just use the confirmText parameter to be sure alertManager.showAlert(title = "Confirm Title", confirmText = "Yes", onConfirm = { confirmClicked = true }) - composeTestRule.onNodeWithText("Yes").performClick() + onNodeWithText("Yes").performClick() - assert(confirmClicked) - composeTestRule.onNodeWithText("Confirm Title").assertDoesNotExist() + assertTrue(confirmClicked) + onNodeWithText("Confirm Title").assertDoesNotExist() } } diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md index 68ed44809..4d225d58c 100644 --- a/docs/decisions/architecture-review-2026-03.md +++ b/docs/decisions/architecture-review-2026-03.md @@ -161,16 +161,16 @@ Android uses `@Module`-annotated classes (`CoreDataModule`, `CoreBleAndroidModul ### D1. Zero `commonTest` in feature modules *(resolved 2026-03-12)* -| Module | `commonTest` | `test`/`androidUnitTest` | `androidTest` | -|---|---:|---:|---:| -| `feature:settings` | 22 | 20 | 15 | -| `feature:node` | 24 | 9 | 0 | -| `feature:messaging` | 18 | 5 | 3 | -| `feature:connections` | 27 | 0 | 0 | -| `feature:firmware` | 15 | 25 | 0 | -| `feature:wifi-provision` | 62 | 0 | 0 | +| Module | `commonTest` | `test`/`androidUnitTest` | +|---|---:|---:| +| `feature:settings` | 33 | 20 | +| `feature:node` | 24 | 9 | +| `feature:messaging` | 21 | 5 | +| `feature:connections` | 27 | 0 | +| `feature:firmware` | 15 | 25 | +| `feature:wifi-provision` | 62 | 0 | -**Outcome:** All 8 feature modules now have `commonTest` coverage (193 shared tests). Combined with 70 platform unit tests and 18 instrumented tests, feature modules have 281 tests total. +**Outcome:** All 8 feature modules now have `commonTest` coverage (211 shared tests). Combined with 70 platform unit tests, feature modules have 281 tests total. All Compose UI tests have been migrated from `androidTest` to `commonTest` using CMP `runComposeUiTest`; instrumented test infrastructure has been removed from CI. ### D2. No shared test fixtures *(resolved 2026-03-12)* diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index c654e6e6f..a1b35c797 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -59,13 +59,5 @@ kotlin { androidMain.dependencies { implementation(libs.markdown.renderer.android) } commonTest.dependencies { implementation(projects.core.testing) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.test.ext.junit) - } - } } } diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts index 1dc180a42..5429361f5 100644 --- a/feature/intro/build.gradle.kts +++ b/feature/intro/build.gradle.kts @@ -38,12 +38,5 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - } - } } } diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index ebd5ec2c9..db52c350a 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -43,12 +43,5 @@ kotlin { implementation(projects.core.ui) implementation(projects.core.di) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - } - } } } diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index e06b417b7..80eed61c5 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -24,7 +24,6 @@ kotlin { android { namespace = "org.meshtastic.feature.messaging" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -56,6 +55,8 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) } - val androidHostTest by getting { dependencies { implementation(libs.androidx.work.testing) } } + commonTest.dependencies { implementation(libs.compose.multiplatform.ui.test) } + + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt similarity index 81% rename from feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt rename to feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 30f65afff..68f7817aa 100644 --- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -16,25 +16,21 @@ */ package org.meshtastic.feature.messaging.component +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.tooling.preview.NodePreviewParameterProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith +import androidx.compose.ui.test.runComposeUiTest import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) class MessageItemTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun mqttIconIsDisplayedWhenViaMqttIsTrue() { + fun mqttIconIsDisplayedWhenViaMqttIsTrue() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val messageWithMqtt = Message( @@ -56,7 +52,7 @@ class MessageItemTest { viaMqtt = true, ) - composeTestRule.setContent { + setContent { MessageItem( message = messageWithMqtt, node = testNode, @@ -69,11 +65,11 @@ class MessageItemTest { } // Check that the MQTT icon is displayed - composeTestRule.onNodeWithContentDescription("via MQTT").assertIsDisplayed() + onNodeWithContentDescription("via MQTT").assertIsDisplayed() } @Test - fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() { + fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val messageWithoutMqtt = Message( @@ -95,7 +91,7 @@ class MessageItemTest { viaMqtt = false, ) - composeTestRule.setContent { + setContent { MessageItem( message = messageWithoutMqtt, node = testNode, @@ -108,11 +104,11 @@ class MessageItemTest { } // Check that the MQTT icon is not displayed - composeTestRule.onNodeWithContentDescription("via MQTT").assertDoesNotExist() + onNodeWithContentDescription("via MQTT").assertDoesNotExist() } @Test - fun messageItem_hasCorrectSemanticContentDescription() { + fun messageItem_hasCorrectSemanticContentDescription() = runComposeUiTest { val testNode = NodePreviewParameterProvider().minnieMouse val message = Message( @@ -134,7 +130,7 @@ class MessageItemTest { viaMqtt = false, ) - composeTestRule.setContent { + setContent { MessageItem( message = message, node = testNode, @@ -147,8 +143,6 @@ class MessageItemTest { } // Verify that the node containing the message text exists and matches the text - composeTestRule - .onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World") - .assertIsDisplayed() + onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World").assertIsDisplayed() } } diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 6195fb13b..0d89b55f6 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -62,14 +62,5 @@ kotlin { } androidMain.dependencies { implementation(libs.markdown.renderer.android) } - - val androidHostTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.androidx.test.ext.junit) - } - } } } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 4b868fbc4..2793f3625 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -26,7 +26,6 @@ kotlin { android { namespace = "org.meshtastic.feature.settings" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -57,17 +56,11 @@ kotlin { implementation(libs.androidx.appcompat) } - commonTest.dependencies { implementation(project(":core:datastore")) } - - val androidHostTest by getting { - dependencies { - implementation(project(":core:datastore")) - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.androidx.compose.ui.test.manifest) - implementation(libs.androidx.test.ext.junit) - } + commonTest.dependencies { + implementation(project(":core:datastore")) + implementation(libs.compose.multiplatform.ui.test) } + + jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt deleted file mode 100644 index 9eb31a6e7..000000000 --- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings.radio.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getString -import org.meshtastic.core.resources.i_agree -import org.meshtastic.core.resources.map_reporting -import org.meshtastic.core.resources.map_reporting_summary - -@RunWith(AndroidJUnit4::class) -class MapReportingPreferenceTest { - - @get:Rule val composeTestRule = createComposeRule() - - private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id) - - var mapReportingEnabled = false - var shouldReportLocation = false - var positionPrecision = 5 - var positionReportingInterval = 60 - - var mapReportingEnabledChanged = { enabled: Boolean -> mapReportingEnabled = enabled } - var shouldReportLocationChanged = { enabled: Boolean -> shouldReportLocation = enabled } - var positionPrecisionChanged = { precision: Int -> positionPrecision = precision } - var positionReportingIntervalChanged = { interval: Int -> positionReportingInterval = interval } - - private fun testMapReportingPreference() = composeTestRule.setContent { - Column { - MapReportingPreference( - mapReportingEnabled = mapReportingEnabled, - shouldReportLocation = shouldReportLocation, - positionPrecision = positionPrecision, - onMapReportingEnabledChanged = mapReportingEnabledChanged, - onShouldReportLocationChanged = shouldReportLocationChanged, - onPositionPrecisionChanged = positionPrecisionChanged, - publishIntervalSecs = positionReportingInterval, - onPublishIntervalSecsChanged = positionReportingIntervalChanged, - enabled = true, - ) - } - } - - @Test - fun testMapReportingPreference_showsText() { - composeTestRule.apply { - testMapReportingPreference() - // Verify that the dialog title is displayed - onNodeWithText(getString(Res.string.map_reporting)).assertIsDisplayed() - onNodeWithText(getString(Res.string.map_reporting_summary)).assertIsDisplayed() - } - } - - @Test - fun testMapReportingPreference_toggleMapReporting() { - composeTestRule.apply { - testMapReportingPreference() - onNodeWithText(getString(Res.string.i_agree)).assertIsNotDisplayed() - onNodeWithText(getString(Res.string.map_reporting)).performClick() - Assert.assertFalse(mapReportingEnabled) - Assert.assertFalse(shouldReportLocation) - onNodeWithText(getString(Res.string.i_agree)).assertIsDisplayed() - onNodeWithText(getString(Res.string.i_agree)).performClick() - Assert.assertTrue(shouldReportLocation) - Assert.assertTrue(mapReportingEnabled) - onNodeWithText(getString(Res.string.map_reporting)).performClick() - onNodeWithText(getString(Res.string.i_agree)).assertIsNotDisplayed() - Assert.assertTrue(shouldReportLocation) - Assert.assertFalse(mapReportingEnabled) - } - } -} diff --git a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt similarity index 71% rename from feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt index b768528e9..f68a79f23 100644 --- a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt @@ -23,17 +23,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.unit.dp -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_active_filters import org.meshtastic.core.resources.debug_default_search @@ -42,18 +39,15 @@ import org.meshtastic.core.resources.getString import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchMatch import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchState -import org.robolectric.annotation.Config +import kotlin.test.Test -@RunWith(AndroidJUnit4::class) -@Config(sdk = [34]) +@OptIn(ExperimentalTestApi::class) class DebugSearchTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun debugSearchBar_showsPlaceholder() { + fun debugSearchBar_showsPlaceholder() = runComposeUiTest { val placeholder = getString(Res.string.debug_default_search) - composeTestRule.setContent { + setContent { DebugSearchBar( searchState = SearchState(), onSearchTextChange = {}, @@ -62,13 +56,13 @@ class DebugSearchTest { onClearSearch = {}, ) } - composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() + onNodeWithText(placeholder).assertIsDisplayed() } @Test - fun debugSearchBar_showsClearButtonWhenTextEntered() { + fun debugSearchBar_showsClearButtonWhenTextEntered() = runComposeUiTest { val placeholder = getString(Res.string.debug_default_search) - composeTestRule.setContent { + setContent { var searchText by remember { mutableStateOf("test") } DebugSearchBar( searchState = SearchState(searchText = searchText), @@ -78,17 +72,17 @@ class DebugSearchTest { onClearSearch = { searchText = "" }, ) } - composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick() - composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() + onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick() + onNodeWithText(placeholder).assertIsDisplayed() } @Test - fun debugSearchBar_searchFor_showsArrowsClearAndValues() { + fun debugSearchBar_searchFor_showsArrowsClearAndValues() = runComposeUiTest { val searchText = "test" val matchCount = 3 val currentMatchIndex = 1 - composeTestRule.setContent { + setContent { DebugSearchBar( searchState = SearchState( @@ -104,18 +98,18 @@ class DebugSearchTest { ) } // Check the match count display (e.g., '2/3') - composeTestRule.onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() + onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() // Check the navigation arrows - composeTestRule.onNodeWithContentDescription("Previous match").assertIsDisplayed() - composeTestRule.onNodeWithContentDescription("Next match").assertIsDisplayed() + onNodeWithContentDescription("Previous match").assertIsDisplayed() + onNodeWithContentDescription("Next match").assertIsDisplayed() // Check the clear button - composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed() + onNodeWithContentDescription("Clear search").assertIsDisplayed() } @Test - fun debugFilterBar_showsFilterButtonAndMenu() { + fun debugFilterBar_showsFilterButtonAndMenu() = runComposeUiTest { val filterLabel = getString(Res.string.debug_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf()) } var customFilterText by remember { mutableStateOf("") } val presetFilters = listOf("Error", "Warning", "Info") @@ -138,13 +132,13 @@ class DebugSearchTest { ) } // The filter button should be visible - composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed() + onNodeWithText(filterLabel).assertIsDisplayed() } @Test - fun debugFilterBar_addCustomFilter_displaysActiveFilter() { + fun debugFilterBar_addCustomFilter_displaysActiveFilter() = runComposeUiTest { val activeFiltersLabel = getString(Res.string.debug_active_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf()) } var customFilterText by remember { mutableStateOf("") } Column(modifier = Modifier.padding(16.dp)) { @@ -162,18 +156,16 @@ class DebugSearchTest { ) } } - with(composeTestRule) { - onNodeWithText("Add custom filter").performTextInput("MyFilter") - onNodeWithContentDescription("Add filter").performClick() - onNodeWithText(activeFiltersLabel).assertIsDisplayed() - onNodeWithText("MyFilter").assertIsDisplayed() - } + onNodeWithText("Add custom filter").performTextInput("MyFilter") + onNodeWithContentDescription("Add filter").performClick() + onNodeWithText(activeFiltersLabel).assertIsDisplayed() + onNodeWithText("MyFilter").assertIsDisplayed() } @Test - fun debugActiveFilters_clearAllFilters_removesFilters() { + fun debugActiveFilters_clearAllFilters_removesFilters() = runComposeUiTest { val activeFiltersLabel = getString(Res.string.debug_active_filters) - composeTestRule.setContent { + setContent { var filterTexts by remember { mutableStateOf(listOf("A", "B")) } DebugActiveFilters( filterTexts = filterTexts, @@ -183,13 +175,13 @@ class DebugSearchTest { ) } // The active filters label and chips should be visible - composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() - composeTestRule.onNodeWithText("A").assertIsDisplayed() - composeTestRule.onNodeWithText("B").assertIsDisplayed() + onNodeWithText(activeFiltersLabel).assertIsDisplayed() + onNodeWithText("A").assertIsDisplayed() + onNodeWithText("B").assertIsDisplayed() // Click the clear all filters button - composeTestRule.onNodeWithContentDescription("Clear all filters").performClick() + onNodeWithContentDescription("Clear all filters").performClick() // The filter chips should no longer be visible - composeTestRule.onNodeWithText("A").assertDoesNotExist() - composeTestRule.onNodeWithText("B").assertDoesNotExist() + onNodeWithText("A").assertDoesNotExist() + onNodeWithText("B").assertDoesNotExist() } } diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt similarity index 54% rename from feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt index 1f390e44e..61d3b1219 100644 --- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt @@ -16,27 +16,24 @@ */ package org.meshtastic.feature.settings.radio.component +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith +import androidx.compose.ui.test.runComposeUiTest import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.save import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalTestApi::class) class EditDeviceProfileDialogTest { - @get:Rule val composeTestRule = createComposeRule() - private val title = "Export configuration" private val deviceProfile = DeviceProfile( @@ -46,61 +43,61 @@ class EditDeviceProfileDialogTest { fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138), ) - private fun testEditDeviceProfileDialog(onDismiss: () -> Unit = {}, onConfirm: (DeviceProfile) -> Unit = {}) = - composeTestRule.setContent { + @Test + fun testEditDeviceProfileDialog_showsDialogTitle() = runComposeUiTest { + setContent { + EditDeviceProfileDialog(title = title, deviceProfile = deviceProfile, onConfirm = {}, onDismiss = {}) + } + + // Verify that the dialog title is displayed + onNodeWithText(title).assertIsDisplayed() + } + + @Test + fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() = runComposeUiTest { + setContent { + EditDeviceProfileDialog(title = title, deviceProfile = deviceProfile, onConfirm = {}, onDismiss = {}) + } + + // Verify the "Cancel" and "Save" buttons are displayed + onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed() + onNodeWithText(getString(Res.string.save)).assertIsDisplayed() + } + + @Test + fun testEditDeviceProfileDialog_clickCancelButton() = runComposeUiTest { + var onDismissClicked = false + setContent { EditDeviceProfileDialog( title = title, deviceProfile = deviceProfile, - onConfirm = onConfirm, - onDismiss = onDismiss, + onConfirm = {}, + onDismiss = { onDismissClicked = true }, ) } - @Test - fun testEditDeviceProfileDialog_showsDialogTitle() { - composeTestRule.apply { - testEditDeviceProfileDialog() - - // Verify that the dialog title is displayed - onNodeWithText(title).assertIsDisplayed() - } - } - - @Test - fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() { - composeTestRule.apply { - testEditDeviceProfileDialog() - - // Verify the "Cancel" and "Save" buttons are displayed - onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed() - onNodeWithText(getString(Res.string.save)).assertIsDisplayed() - } - } - - @Test - fun testEditDeviceProfileDialog_clickCancelButton() { - var onDismissClicked = false - composeTestRule.apply { - testEditDeviceProfileDialog(onDismiss = { onDismissClicked = true }) - - // Click the "Cancel" button - onNodeWithText(getString(Res.string.cancel)).performClick() - } + // Click the "Cancel" button + onNodeWithText(getString(Res.string.cancel)).performClick() // Verify onDismiss is called - Assert.assertTrue(onDismissClicked) + assertTrue(onDismissClicked) } @Test - fun testEditDeviceProfileDialog_addChannels() { + fun testEditDeviceProfileDialog_addChannels() = runComposeUiTest { var actualDeviceProfile: DeviceProfile? = null - composeTestRule.apply { - testEditDeviceProfileDialog(onConfirm = { actualDeviceProfile = it }) - - onNodeWithText(getString(Res.string.save)).performClick() + setContent { + EditDeviceProfileDialog( + title = title, + deviceProfile = deviceProfile, + onConfirm = { actualDeviceProfile = it }, + onDismiss = {}, + ) } + onNodeWithText(getString(Res.string.save)).performClick() + // Verify onConfirm is called with the correct DeviceProfile - Assert.assertEquals(deviceProfile, actualDeviceProfile) + assertEquals(deviceProfile, actualDeviceProfile) } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt new file mode 100644 index 000000000..850cc93e7 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.i_agree +import org.meshtastic.core.resources.map_reporting +import org.meshtastic.core.resources.map_reporting_summary +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class MapReportingPreferenceTest { + + var mapReportingEnabled = false + var shouldReportLocation = false + var positionPrecision = 5 + var positionReportingInterval = 60 + + var mapReportingEnabledChanged = { enabled: Boolean -> mapReportingEnabled = enabled } + var shouldReportLocationChanged = { enabled: Boolean -> shouldReportLocation = enabled } + var positionPrecisionChanged = { precision: Int -> positionPrecision = precision } + var positionReportingIntervalChanged = { interval: Int -> positionReportingInterval = interval } + + @Test + fun testMapReportingPreference_showsText() = runComposeUiTest { + setContent { + Column { + MapReportingPreference( + mapReportingEnabled = mapReportingEnabled, + shouldReportLocation = shouldReportLocation, + positionPrecision = positionPrecision, + onMapReportingEnabledChanged = mapReportingEnabledChanged, + onShouldReportLocationChanged = shouldReportLocationChanged, + onPositionPrecisionChanged = positionPrecisionChanged, + publishIntervalSecs = positionReportingInterval, + onPublishIntervalSecsChanged = positionReportingIntervalChanged, + enabled = true, + ) + } + } + // Verify that the dialog title is displayed + onNodeWithText(getString(Res.string.map_reporting)).assertIsDisplayed() + onNodeWithText(getString(Res.string.map_reporting_summary)).assertIsDisplayed() + } + + @Test + fun testMapReportingPreference_toggleMapReporting() = runComposeUiTest { + setContent { + Column { + MapReportingPreference( + mapReportingEnabled = mapReportingEnabled, + shouldReportLocation = shouldReportLocation, + positionPrecision = positionPrecision, + onMapReportingEnabledChanged = mapReportingEnabledChanged, + onShouldReportLocationChanged = shouldReportLocationChanged, + onPositionPrecisionChanged = positionPrecisionChanged, + publishIntervalSecs = positionReportingInterval, + onPublishIntervalSecsChanged = positionReportingIntervalChanged, + enabled = true, + ) + } + } + onNodeWithText(getString(Res.string.i_agree)).assertDoesNotExist() + onNodeWithText(getString(Res.string.map_reporting)).performClick() + assertFalse(mapReportingEnabled) + assertFalse(shouldReportLocation) + onNodeWithText(getString(Res.string.i_agree)).assertIsDisplayed() + onNodeWithText(getString(Res.string.i_agree)).performClick() + assertTrue(shouldReportLocation) + assertTrue(mapReportingEnabled) + onNodeWithText(getString(Res.string.map_reporting)).performClick() + onNodeWithText(getString(Res.string.i_agree)).assertDoesNotExist() + assertTrue(shouldReportLocation) + assertFalse(mapReportingEnabled) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1c5630ab..404b9f80e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -129,6 +129,7 @@ compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtim compose-multiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-multiplatform-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } compose-multiplatform-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } +compose-multiplatform-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "compose-multiplatform" } compose-multiplatform-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } compose-multiplatform-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" } compose-multiplatform-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-multiplatform-material3" } From 5c47256b3fdb1a5f0ba4b187dc9009107c195912 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:45:19 -0500 Subject: [PATCH 031/114] test(prefs): migrate DataStore tests from androidHostTest to commonTest (#5092) --- core/prefs/build.gradle.kts | 2 +- .../core/prefs/filter/FilterPrefsTest.kt | 21 ++++++++++--------- .../notification/NotificationPrefsTest.kt | 21 ++++++++++--------- .../meshtastic/core/prefs/tak/TakPrefsTest.kt | 21 ++++++++++++++----- core/repository/build.gradle.kts | 5 ++++- core/testing/build.gradle.kts | 1 + docs/decisions/architecture-review-2026-03.md | 3 ++- feature/wifi-provision/build.gradle.kts | 1 + 8 files changed, 47 insertions(+), 28 deletions(-) rename core/prefs/src/{androidHostTest => commonTest}/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt (84%) rename core/prefs/src/{androidHostTest => commonTest}/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt (85%) rename core/prefs/src/{androidHostTest => commonTest}/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt (79%) diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts index eba3604d7..96bba529e 100644 --- a/core/prefs/build.gradle.kts +++ b/core/prefs/build.gradle.kts @@ -24,7 +24,7 @@ kotlin { android { namespace = "org.meshtastic.core.prefs" androidResources.enable = false - withHostTest { isIncludeAndroidResources = true } + withHostTest {} } sourceSets { diff --git a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt similarity index 84% rename from core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt rename to core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt index 3ba095531..b38c822fe 100644 --- a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt +++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt @@ -22,18 +22,22 @@ import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import okio.FileSystem +import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FilterPrefs -import java.io.File import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) class FilterPrefsTest { - private lateinit var tmpFolder: File + private lateinit var tmpDir: Path private lateinit var dataStore: DataStore private lateinit var filterPrefs: FilterPrefs @@ -44,15 +48,12 @@ class FilterPrefsTest { @BeforeTest fun setup() { - tmpFolder = - File.createTempFile("filterPrefsTest", null).apply { - delete() - mkdirs() - } + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "filterPrefsTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) dataStore = - PreferenceDataStoreFactory.create( + PreferenceDataStoreFactory.createWithPath( scope = testScope, - produceFile = { File(tmpFolder, "test.preferences_pb").also { it.createNewFile() } }, + produceFile = { tmpDir / "test.preferences_pb" }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) filterPrefs = FilterPrefsImpl(dataStore, dispatchers) @@ -60,7 +61,7 @@ class FilterPrefsTest { @AfterTest fun tearDown() { - tmpFolder.deleteRecursively() + FileSystem.SYSTEM.deleteRecursively(tmpDir) } @Test fun `filterEnabled defaults to false`() = testScope.runTest { assertFalse(filterPrefs.filterEnabled.value) } diff --git a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt similarity index 85% rename from core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt rename to core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt index 51571786c..a5792e800 100644 --- a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt +++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt @@ -22,17 +22,21 @@ import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import okio.FileSystem +import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.NotificationPrefs -import java.io.File import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) class NotificationPrefsTest { - private lateinit var tmpFolder: File + private lateinit var tmpDir: Path private lateinit var dataStore: DataStore private lateinit var notificationPrefs: NotificationPrefs @@ -43,15 +47,12 @@ class NotificationPrefsTest { @BeforeTest fun setup() { - tmpFolder = - File.createTempFile("notificationPrefsTest", null).apply { - delete() - mkdirs() - } + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "notificationPrefsTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) dataStore = - PreferenceDataStoreFactory.create( + PreferenceDataStoreFactory.createWithPath( scope = testScope, - produceFile = { File(tmpFolder, "test.preferences_pb").also { it.createNewFile() } }, + produceFile = { tmpDir / "test.preferences_pb" }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) notificationPrefs = NotificationPrefsImpl(dataStore, dispatchers) @@ -59,7 +60,7 @@ class NotificationPrefsTest { @AfterTest fun tearDown() { - tmpFolder.deleteRecursively() + FileSystem.SYSTEM.deleteRecursively(tmpDir) } @Test diff --git a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt similarity index 79% rename from core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt rename to core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt index caa60fe70..2ad0ad21c 100644 --- a/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt +++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt @@ -22,17 +22,21 @@ import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.rules.TemporaryFolder +import okio.FileSystem +import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.TakPrefs +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalUuidApi::class) class TakPrefsTest { - @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + private lateinit var tmpDir: Path private lateinit var dataStore: DataStore private lateinit var takPrefs: TakPrefs @@ -43,15 +47,22 @@ class TakPrefsTest { @BeforeTest fun setup() { + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "takPrefsTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) dataStore = - PreferenceDataStoreFactory.create( + PreferenceDataStoreFactory.createWithPath( scope = testScope, - produceFile = { tmpFolder.newFile("test.preferences_pb") }, + produceFile = { tmpDir / "test.preferences_pb" }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) takPrefs = TakPrefsImpl(dataStore, dispatchers) } + @AfterTest + fun tearDown() { + FileSystem.SYSTEM.deleteRecursively(tmpDir) + } + @Test fun `isTakServerEnabled defaults to false`() = testScope.runTest { assertFalse(takPrefs.isTakServerEnabled.value) } diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts index 9eb277575..ce7ac4abc 100644 --- a/core/repository/build.gradle.kts +++ b/core/repository/build.gradle.kts @@ -22,7 +22,10 @@ plugins { kotlin { @Suppress("UnstableApiUsage") - android { androidResources.enable = false } + android { + androidResources.enable = false + withHostTest {} + } sourceSets { commonMain.dependencies { diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 53c361a62..25e1a3d91 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -22,6 +22,7 @@ kotlin { android { namespace = "org.meshtastic.core.testing" androidResources.enable = false + withHostTest {} } sourceSets { diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md index 4d225d58c..be43f823b 100644 --- a/docs/decisions/architecture-review-2026-03.md +++ b/docs/decisions/architecture-review-2026-03.md @@ -181,10 +181,11 @@ Android uses `@Module`-annotated classes (`CoreDataModule`, `CoreBleAndroidModul 36 `commonTest` files exist but are concentrated in `core:domain` (22 files) and `core:data` (10 files). Limited or zero tests in: - `core:service` (has `ServiceRepositoryImpl`, `DirectRadioControllerImpl`, `MeshServiceOrchestrator`) - `core:network` (has `StreamFrameCodecTest` — 10 tests; `TcpTransport` untested) -- `core:prefs` (preference flows, default values) - `core:ble` (connection state machine) - `core:ui` (utility functions) +`core:prefs` now has 12 `commonTest` tests (3 files: `FilterPrefsTest`, `TakPrefsTest`, `NotificationPrefsTest`) migrated from `androidHostTest` using Okio + `PreferenceDataStoreFactory.createWithPath()` for KMP compatibility. + ### D4. Desktop has 2 tests `desktop/src/test/` contains `DesktopKoinTest.kt` and `DesktopTopLevelDestinationParityTest.kt`. Still needs: diff --git a/feature/wifi-provision/build.gradle.kts b/feature/wifi-provision/build.gradle.kts index 4b44b0544..3ce123dec 100644 --- a/feature/wifi-provision/build.gradle.kts +++ b/feature/wifi-provision/build.gradle.kts @@ -24,6 +24,7 @@ kotlin { android { namespace = "org.meshtastic.feature.wifiprovision" androidResources.enable = false + withHostTest {} } sourceSets { From 17d85c88c4ffeb9017884ccb63a2d94f724520b1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:04:13 -0500 Subject: [PATCH 032/114] fix(release): publish GitHub release on promotion instead of staying draft (#5094) --- .github/workflows/promote.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 2338a6aeb..df16866f3 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -139,6 +139,7 @@ jobs: gh release edit ${{ inputs.tag_name }} \ --tag ${{ inputs.final_tag }} \ --title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \ + --draft=false \ --prerelease=${{ inputs.channel != 'production' }} - name: Notify Discord From e424d4d076481909f9ad48c3e15ba80c60e1d1a3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:36:37 -0500 Subject: [PATCH 033/114] fix(build): add explicit compose-multiplatform-animation dependency (#5095) --- .skills/project-overview/SKILL.md | 23 ++++++++++++++++++- AGENTS.md | 3 +++ app/build.gradle.kts | 1 + .../main/kotlin/KmpFeatureConventionPlugin.kt | 1 + core/ui/build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + 6 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md index 0ceade61a..6df668bf2 100644 --- a/.skills/project-overview/SKILL.md +++ b/.skills/project-overview/SKILL.md @@ -62,10 +62,31 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec datadogClientToken=dummy_token ``` -## 5. Troubleshooting +## 5. Workspace Bootstrap (MUST run before any build) +Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you. + +1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it: + ```bash + # Check common macOS/Linux locations in order of preference + if [ -z "$ANDROID_HOME" ]; then + for dir in "$HOME/Library/Android/sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do + if [ -d "$dir" ]; then export ANDROID_HOME="$dir"; break; fi + done + fi + ``` + All `./gradlew` invocations must include `ANDROID_HOME` in the environment. If the SDK cannot be found, ask the user for the path. + +2. **Proto submodule:** `core/proto/src/main/proto` is a Git submodule containing Protobuf definitions. It must be initialized or builds will fail with proto generation errors: + ```bash + git submodule update --init + ``` + +## 6. Troubleshooting - **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts. - **Missing Secrets:** Check `local.properties` (see Environment Setup above). - **JDK Version:** JDK 21 is required. +- **SDK location not found:** See Workspace Bootstrap step 1 above. +- **Proto generation failures:** See Workspace Bootstrap step 2 above. - **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist. - **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`). diff --git a/AGENTS.md b/AGENTS.md index 92009df61..73d29f2b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,9 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes +- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically: + 1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user. + 2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds. - **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing. - **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code. - **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c8ed4c39..2005e9320 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -243,6 +243,7 @@ dependencies { implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.material) + implementation(libs.compose.multiplatform.animation) implementation(libs.compose.multiplatform.material3) implementation(libs.compose.multiplatform.ui.tooling.preview) implementation(libs.compose.multiplatform.ui) diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index 4fef5c6f4..6af52cd50 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -42,6 +42,7 @@ class KmpFeatureConventionPlugin : Plugin { extensions.configure { sourceSets.getByName("commonMain").dependencies { // Compose Multiplatform UI + implementation(libs.library("compose-multiplatform-animation")) implementation(libs.library("compose-multiplatform-material3")) // Lifecycle & ViewModel (JetBrains KMP forks — safe in commonMain) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 76475e096..99221edf1 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -42,6 +42,7 @@ kotlin { implementation(projects.core.resources) implementation(projects.core.service) + implementation(libs.compose.multiplatform.animation) implementation(libs.compose.multiplatform.material3) implementation(libs.compose.multiplatform.ui) implementation(libs.compose.multiplatform.foundation) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 404b9f80e..c62bda180 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -125,6 +125,7 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4 androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.11.0-rc01" } # Compose Multiplatform +compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" } compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-multiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-multiplatform-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } From b0c603c7eddf96c3d16ff1ccf9535405c0449855 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:49:11 -0500 Subject: [PATCH 034/114] fix(build): align AndroidX Compose versions with CMP and migrate to runComposeUiTest (#5096) --- .github/renovate.json | 4 +--- app/build.gradle.kts | 2 +- .../app/ui/NavigationAssemblyTest.kt | 11 +++++------ .../meshtastic/buildlogic/AndroidCompose.kt | 18 ++++++++++++++++++ core/barcode/build.gradle.kts | 2 +- .../core/barcode/BarcodeScannerTest.kt | 12 ++++-------- desktop/build.gradle.kts | 1 + gradle/libs.versions.toml | 8 +++----- 8 files changed, 34 insertions(+), 24 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index c9993abac..0a4ddeab0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -71,7 +71,6 @@ "!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/", "!/^androidx\\.test\\.espresso/", "!/^androidx\\.test\\.ext/", - "!/^androidx\\.compose\\.ui:ui-test-junit4$/", "!/^androidx\\.hilt/" ] }, @@ -118,8 +117,7 @@ "groupSlug": "androidx-testing", "matchPackageNames": [ "/^androidx\\.test\\.espresso/", - "/^androidx\\.test\\.ext/", - "/^androidx\\.compose\\.ui:ui-test-junit4$/" + "/^androidx\\.test\\.ext/" ], "automerge": true }, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2005e9320..0942756c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -305,7 +305,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) - testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.compose.multiplatform.ui.test) testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.glance.appwidget) } diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index 0665d50db..de6062d33 100644 --- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -16,12 +16,12 @@ */ package org.meshtastic.app.ui -import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import kotlinx.coroutines.flow.emptyFlow -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.meshtastic.core.navigation.NodesRoute @@ -35,15 +35,14 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +@OptIn(ExperimentalTestApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class NavigationAssemblyTest { - @get:Rule val composeTestRule = createComposeRule() - @Test - fun verifyNavigationGraphsAssembleWithoutCrashing() { - composeTestRule.setContent { + fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest { + setContent { val backStack = rememberNavBackStack(NodesRoute.NodesGraph) entryProvider { contactsGraph(backStack, emptyFlow()) diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt index 1d4e2ea56..0768629fc 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt @@ -24,6 +24,24 @@ import org.gradle.kotlin.dsl.dependencies internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { commonExtension.apply { buildFeatures.compose = true } + // CMP skips Android version enforcement; third-party BOMs and atomic-group alignment + // can silently override AndroidX Compose versions. Force core groups to the CMP version. + // Material/Material3 excluded — CMP maps those to different AndroidX version numbers. + val cmpVersion = libs.version("compose-multiplatform") + val cmpAlignedGroups = setOf( + "androidx.compose.animation", + "androidx.compose.foundation", + "androidx.compose.runtime", + "androidx.compose.ui", + ) + configurations.configureEach { + resolutionStrategy.eachDependency { + if (requested.group in cmpAlignedGroups) { + useVersion(cmpVersion) + } + } + } + val hasAndroidTest = project.projectDir.resolve("src/androidTest").exists() dependencies { "debugImplementation"(libs.library("compose-multiplatform-ui-tooling")) diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts index c8dbc078e..711cccc09 100644 --- a/core/barcode/build.gradle.kts +++ b/core/barcode/build.gradle.kts @@ -52,6 +52,6 @@ dependencies { testImplementation(libs.junit) testRuntimeOnly(libs.junit.vintage.engine) testImplementation(libs.robolectric) - testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.compose.multiplatform.ui.test) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt index e06562cfb..aa222b7c2 100644 --- a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt +++ b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt @@ -16,21 +16,17 @@ */ package org.meshtastic.core.barcode -import androidx.compose.ui.test.junit4.v2.createComposeRule -import org.junit.Rule +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +@OptIn(ExperimentalTestApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class BarcodeScannerTest { - @get:Rule val composeTestRule = createComposeRule() - - @Test - fun testRememberBarcodeScanner() { - composeTestRule.setContent { rememberBarcodeScanner { _ -> } } - } + @Test fun testRememberBarcodeScanner() = runComposeUiTest { setContent { rememberBarcodeScanner { _ -> } } } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 14075fbda..df5122a4d 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -256,6 +256,7 @@ dependencies { // Compose Desktop implementation(compose.desktop.currentOs) + implementation(libs.compose.multiplatform.animation) implementation(libs.compose.multiplatform.material3) implementation(libs.compose.multiplatform.runtime) implementation(libs.compose.multiplatform.foundation) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c62bda180..dc9d0fe2d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ appcompat = "1.7.1" accompanist = "0.37.3" # androidx -androidxTracing = "1.10.6" datastore = "1.2.1" glance = "1.2.0-rc01" lifecycle = "2.10.0" @@ -118,11 +117,10 @@ androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.2" } androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } -# AndroidX Compose (explicit versions — BOM removed to avoid transitive compileSdk conflicts with CMP adaptive fork) +# AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended", version = "1.7.8" } # Only used by deprecated mesh_service_example — remove when that module is deleted -androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidxTracing" } -androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.11.0-rc01" } -androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.11.0-rc01" } +androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity) # Compose Multiplatform compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" } From 1e29fec469b2743ffe4d273724e189e02be2c96e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:33:44 -0500 Subject: [PATCH 035/114] chore(deps): update androidx (general) to v1.11.0-rc01 (#5099) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: James Rich --- .github/renovate.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/renovate.json b/.github/renovate.json index 0a4ddeab0..e08e3d2f3 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -74,6 +74,14 @@ "!/^androidx\\.hilt/" ] }, + { + "description": "Group JetBrains Compose Multiplatform plugin and libraries (separate versioning from AndroidX Compose)", + "groupName": "Compose Multiplatform (JetBrains)", + "groupSlug": "compose-multiplatform", + "matchPackageNames": [ + "/^org\\.jetbrains\\.compose/" + ] + }, { "description": "Group Kotlin standard library, coroutines, and serialization", "groupName": "Kotlin Ecosystem", @@ -277,6 +285,7 @@ "matchPackageNames": [ "/^org\\.jetbrains\\.kotlin/", "/^org\\.jetbrains\\.kotlinx/", + "/^org\\.jetbrains\\.compose/", "/^com\\.google\\.dagger/", "/^androidx\\.hilt/", "/^com\\.google\\.protobuf/", From a8cdec7f55afec9e33c87460f402552b87ce827b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:09:22 -0500 Subject: [PATCH 036/114] fix(ci): isolate JetBrains Compose Multiplatform in Renovate config (#5102) From 4dd591af25b0b706e0ede4c702ffae4c4d14e305 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:04:58 -0500 Subject: [PATCH 037/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5101) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../src/commonMain/composeResources/values-ru/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index a61d2e1dc..b414c046c 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -866,6 +866,12 @@ Написать сообщение Метрика прохожих PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s Метрики прохожих недоступны Настройка Wi-Fi для mPWRD-OS Устройства Bluetooth From 35bf1fded5197974078489b391dc93a65d83872f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:02:52 -0500 Subject: [PATCH 038/114] build: align Compose Multiplatform versions and exclude transitive BOMs (#5103) --- .../meshtastic/buildlogic/AndroidCompose.kt | 22 ++++++++++++++++--- gradle/libs.versions.toml | 3 ++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt index 0768629fc..b438fe6c6 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt @@ -24,9 +24,17 @@ import org.gradle.kotlin.dsl.dependencies internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { commonExtension.apply { buildFeatures.compose = true } - // CMP skips Android version enforcement; third-party BOMs and atomic-group alignment - // can silently override AndroidX Compose versions. Force core groups to the CMP version. - // Material/Material3 excluded — CMP maps those to different AndroidX version numbers. + // CMP is the sole Compose version authority (BOM removed from the catalog). + // Third-party libraries (maps-compose, datadog, etc.) carry a transitive + // compose-bom whose constraints conflict with CMP-published AndroidX artifacts. + // Exclude it globally so CMP's own dependency graph wins. + configurations.configureEach { + exclude(mapOf("group" to "androidx.compose", "module" to "compose-bom")) + } + + // CMP publishes these core AndroidX groups at the CMP version tag. + // Material, Material3, and Adaptive follow separate AndroidX version numbers + // and must NOT be included here (see CMP release notes for the mapping table). val cmpVersion = libs.version("compose-multiplatform") val cmpAlignedGroups = setOf( "androidx.compose.animation", @@ -34,10 +42,18 @@ internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { "androidx.compose.runtime", "androidx.compose.ui", ) + + // The BOM exclusion above strips versions from transitive material deps + // (e.g. maps-compose-widgets, datadog). Pin the material group to the + // AndroidX version that matches this CMP release. + val materialVersion = libs.version("androidx-compose-material") + configurations.configureEach { resolutionStrategy.eachDependency { if (requested.group in cmpAlignedGroups) { useVersion(cmpVersion) + } else if (requested.group == "androidx.compose.material") { + useVersion(materialVersion) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc9d0fe2d..52d30d1ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" compose-multiplatform-material3 = "1.11.0-alpha06" +androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" # Google @@ -118,7 +119,7 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) -androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended", version = "1.7.8" } # Only used by deprecated mesh_service_example — remove when that module is deleted +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose-material" } # Only used by deprecated mesh_service_example — remove when that module is deleted androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity) From 39620d063b90cdbdebcd69c7646a09d6e29507f3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:25:21 -0500 Subject: [PATCH 039/114] fix(nav): restore broken traceroute map navigation (#5104) --- .../core/navigation/MultiBackstackTest.kt | 31 +++++++++++++++++++ .../core/ui/component/MeshtasticAppShell.kt | 8 +++-- .../ui/component/TracerouteAlertHandler.kt | 7 ++++- .../core/ui/util/AlertManagerTest.kt | 23 ++++++++++++++ .../feature/node/metrics/MetricsViewModel.kt | 7 +++-- .../feature/node/metrics/TracerouteLog.kt | 1 - 6 files changed, 71 insertions(+), 6 deletions(-) diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt index c4d3ac044..c36375356 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt @@ -111,4 +111,35 @@ class MultiBackstackTest { assertEquals(2, multiBackstack.activeBackStack.size) assertEquals(SettingsRoute.About, multiBackstack.activeBackStack.last()) } + + @Test + fun `handleDeepLink from different tab switches tab and sets stack`() { + // Start on Connections tab + val startTab = TopLevelDestination.Connections.route + val multiBackstack = MultiBackstack(startTab) + + val connectionsStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Connections.route)) } + val nodesStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route)) } + + multiBackstack.backStacks = + mapOf( + TopLevelDestination.Connections.route to connectionsStack, + TopLevelDestination.Nodes.route to nodesStack, + ) + + // Verify we start on Connections + assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute) + + // Deep-link to a TracerouteMap on the Nodes tab (this is the exact pattern + // MeshtasticAppShell uses for traceroute alert "View on Map") + val tracerouteMap = NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 42, logUuid = "abc") + multiBackstack.handleDeepLink(listOf(NodesRoute.NodesGraph, tracerouteMap)) + + // Should have switched to the Nodes tab + assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute) + // Stack should contain the graph root + the traceroute map route + assertEquals(2, multiBackstack.activeBackStack.size) + assertEquals(NodesRoute.NodesGraph, multiBackstack.activeBackStack.first()) + assertEquals(tracerouteMap, multiBackstack.activeBackStack.last()) + } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt index 8c96e88a4..153f5a058 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.NodeDetailRoute +import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.ui.viewmodel.UIViewModel /** @@ -43,8 +44,11 @@ fun MeshtasticAppShell( MeshtasticCommonAppSetup( uiViewModel = uiViewModel, onNavigateToTracerouteMap = { destNum, requestId, logUuid -> - multiBackstack.activeBackStack.add( - NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), + multiBackstack.handleDeepLink( + listOf( + NodesRoute.NodesGraph, + NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), + ), ) }, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt index 100c6fecb..815f9beb7 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt @@ -26,9 +26,11 @@ import androidx.compose.runtime.LaunchedEffect 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.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.okay import org.meshtastic.core.resources.traceroute @@ -52,6 +54,7 @@ fun TracerouteAlertHandler( val traceRouteResponse by uiViewModel.tracerouteResponse.collectAsStateWithLifecycle(null) var dismissedTracerouteRequestId by remember { mutableStateOf(null) } val colorScheme = MaterialTheme.colorScheme + val scope = rememberCoroutineScope() LaunchedEffect(traceRouteResponse, dismissedTracerouteRequestId) { val response = traceRouteResponse @@ -83,8 +86,10 @@ fun TracerouteAlertHandler( dismissedTracerouteRequestId = response.requestId onNavigateToMap(response.destinationNodeNum, response.requestId, response.logUuid) } else { - uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) uiViewModel.clearTracerouteResponse() + // Post the error alert after the current alert is dismissed to avoid + // the wrapping dismissAlert() in AlertManager immediately clearing it. + scope.launch { uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) } } }, dismissTextRes = Res.string.okay, diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt index d221aeb39..db0560e90 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt @@ -68,4 +68,27 @@ class AlertManagerTest { assertEquals(true, dismissClicked) assertNull(alertManager.currentAlert.value) } + + @Test + fun showAlert_inside_onConfirm_is_dismissed_by_wrapping_dismissAlert() { + // Documents the known race condition: AlertManager wraps onConfirm to call + // dismissAlert() AFTER the user callback, so a showAlert() inside onConfirm + // gets immediately cleared. Callers must defer via launch {} to work around this. + alertManager.showAlert( + title = "First", + onConfirm = { + // This simulates an error path where onConfirm shows a follow-up alert + alertManager.showAlert(title = "Second", message = "Error details") + }, + ) + + // Trigger the wrapped onConfirm (user callback + dismissAlert) + alertManager.currentAlert.value?.onConfirm?.invoke() + + // The second alert is wiped by dismissAlert() — currentAlert is null + assertNull( + alertManager.currentAlert.value, + "showAlert inside onConfirm is cleared by the wrapping dismissAlert; callers must defer via launch {}", + ) + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 8c6ca9222..8a051aaf2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -277,7 +277,6 @@ open class MetricsViewModel( responseLogUuid: String, overlay: TracerouteOverlay?, onViewOnMap: (Int, String) -> Unit, - onShowError: (StringResource) -> Unit, ) { viewModelScope.launch { val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first() @@ -300,7 +299,11 @@ open class MetricsViewModel( ) val errorRes = availability.toMessageRes() if (errorRes != null) { - onShowError(errorRes) + // Post the error alert after the current alert is dismissed to avoid + // the wrapping dismissAlert() in AlertManager immediately clearing it. + viewModelScope.launch { + alertManager.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) + } } else { onViewOnMap(requestId, responseLogUuid) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index caf3e1938..163bdb4f9 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -361,7 +361,6 @@ private fun showTracerouteDetail( responseLogUuid = result.uuid, overlay = overlay, onViewOnMap = onViewOnMap, - onShowError = {}, ) } From 048c74db13613949816643a93d6935601375931c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:37:53 -0500 Subject: [PATCH 040/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5105) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- app/src/main/assets/firmware_releases.json | 2 +- .../commonMain/composeResources/values-ro/strings.xml | 10 ++++++++++ fastlane/metadata/android/ro-RO/changelogs/default.txt | 2 +- fastlane/metadata/android/ro-RO/short_description.txt | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json index c639f39e2..4859e45cf 100644 --- a/app/src/main/assets/firmware_releases.json +++ b/app/src/main/assets/firmware_releases.json @@ -29,7 +29,7 @@ "title": "Meshtastic Firmware 2.7.21.1370b23 Alpha", "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.21.1370b23", "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.21.1370b23/firmware-2.7.21.1370b23.json", - "release_notes": "> [!WARNING]\r\n> Due to resource constraints the HTTP server is deprecated on original-generation ESP32 devices and should not be relied on going forward.\r\n> Support continues to be available on ESP32-S3 and other newer ESP32 generations.\r\n\r\n## 🚀 Enhancements\r\n\r\n- T5-4.7-S3 Epaper Pro support by @mverch67 in https://github.com/meshtastic/firmware/pull/6625\r\n- Xiao NRF - define suitable i2c pins for the sub-variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/8866\r\n- Fix(MQTT): Send first MapReport as soon as possible by @ndoo in https://github.com/meshtastic/firmware/pull/8872\r\n- Feat/add sfa30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9372\r\n- Improved Periodic class by @harry-iii-lord in https://github.com/meshtastic/firmware/pull/9501\r\n- InkHUD: Allow non-system applets to subscribe to input events by @Vortetty in https://github.com/meshtastic/firmware/pull/9514\r\n- Cardputer Kit by @caveman99 in https://github.com/meshtastic/firmware/pull/9540\r\n- Skip header items when enabling the InkHUD menu cursor by @zeropt in https://github.com/meshtastic/firmware/pull/9552\r\n- ExternalNotification and StatusLED now call AmbientLighting to update… by @jp-bennett in https://github.com/meshtastic/firmware/pull/9554\r\n- BaseUI: Favorite Screen Signal Quality improvement by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9566\r\n- Add battery curve for T-Beam 1 watt by @jp-bennett in https://github.com/meshtastic/firmware/pull/9585\r\n- Add sdl libs for native builds by @jp-bennett in https://github.com/meshtastic/firmware/pull/9595\r\n- Log `rxBad` PacketHeaders with more info (`id`, `relay_node`) like `printPacket` by @compumike in https://github.com/meshtastic/firmware/pull/9614\r\n- Develop to master by @thebentern in https://github.com/meshtastic/firmware/pull/9618\r\n- Fix a lot of low level cppcheck warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9623\r\n- Convert `GPS*` global and some new in gps.cpp to `unique_ptr` by @Jorropo in https://github.com/meshtastic/firmware/pull/9628\r\n- Replace delete in RedirectablePrint.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9642\r\n- Replace delete in EInkDynamicDisplay.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9643\r\n- Replace delete in RadioInterface.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9645\r\n- Replace delete in CryptoEngine.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9649\r\n- Replace delete in AudioThread.h with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9651\r\n- Scaling tweaks by @NomDeTom in https://github.com/meshtastic/firmware/pull/9653\r\n- InkHUD: Favorite Map Applet by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9654\r\n- Fake IAQ values on Non-BSEC2 platforms like Platformio and the original ESP32 by @caveman99 in https://github.com/meshtastic/firmware/pull/9663\r\n- #9623 resolved a local shadow of next_key by converting it to int. by @caveman99 in https://github.com/meshtastic/firmware/pull/9665\r\n- Zip a few gitrefs down by @caveman99 in https://github.com/meshtastic/firmware/pull/9672\r\n- Limit http connections and add free heap check before allocating for SSL by @thebentern in https://github.com/meshtastic/firmware/pull/9693\r\n- Split module includes for AQ module by @oscgonfer in https://github.com/meshtastic/firmware/pull/9711\r\n- Align telemetry broadcast want_response behavior with traceroute by @thebentern in https://github.com/meshtastic/firmware/pull/9717\r\n- InkHUD: Nodelist cleanup by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9737\r\n- Add GPIO_DETECT_PA portduino config, and support 13302 detection with it by @jp-bennett in https://github.com/meshtastic/firmware/pull/9741\r\n- Remove unused global rIf that shadows locals and fails cppcheck by @weebl2000 in https://github.com/meshtastic/firmware/pull/9743\r\n- Add Transmit history persistence for respecting traffic intervals between reboots by @thebentern in https://github.com/meshtastic/firmware/pull/9748\r\n- Add new configuration files for LR11xx variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/9761\r\n- Unlock 0x8B5 register macro guard for SX162 by @thebentern in https://github.com/meshtastic/firmware/pull/9777\r\n- Enhancement(mesh): remove late packets from tx queue when full by @m1nl in https://github.com/meshtastic/firmware/pull/9779\r\n- Add json file rotation option by @jp-bennett in https://github.com/meshtastic/firmware/pull/9783\r\n- PPA: Remove Ubuntu 25.04, Add 26.04 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9789\r\n- Deb: Handle offline builds more gracefully by @vidplace7 in https://github.com/meshtastic/firmware/pull/9791\r\n- Remove \"x\" permission bits from some source files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9794\r\n- Add some lora parameter clamping logic to coalesce to defaults and enforce some bounds by @thebentern in https://github.com/meshtastic/firmware/pull/9808\r\n- Add back FEM LNA mode configuration for LoRa by @thebentern in https://github.com/meshtastic/firmware/pull/9809\r\n- More RAK6421 work by @jp-bennett in https://github.com/meshtastic/firmware/pull/9813\r\n- Add ROUTER_LATE and TAK_TRACKER to congestion scaling exemption by @h3lix1 in https://github.com/meshtastic/firmware/pull/9818\r\n- Add ROUTER_LATE to telemetry impolite role check by @h3lix1 in https://github.com/meshtastic/firmware/pull/9819\r\n- Add ROUTER_LATE to infrastructure init and config preservation by @h3lix1 in https://github.com/meshtastic/firmware/pull/9820\r\n- Update Heltec Tracker v2 to version KCT8103L. by @Quency-D in https://github.com/meshtastic/firmware/pull/9822\r\n- Align 920–925 MHz limits as per NBTC regulations in Thailand (27 dBm, 10% duty cycle) by @hereismeaw in https://github.com/meshtastic/firmware/pull/9827\r\n- Add APIPort to native config by @pdxlocations in https://github.com/meshtastic/firmware/pull/9840\r\n- T-mini Eink S3 Support for both InkHUD and BaseUI by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9856\r\n- Consolidate SHTs into one class by @oscgonfer in https://github.com/meshtastic/firmware/pull/9859\r\n- Experiment: C++17 support by @thebentern in https://github.com/meshtastic/firmware/pull/9874\r\n- Remove a bunch of warnings in SEN5X by @oscgonfer in https://github.com/meshtastic/firmware/pull/9884\r\n- BaseUI: Emote Refactoring by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9896\r\n- Add spoof detection for UDP packets in UdpMulticastHandler by @NomDeTom in https://github.com/meshtastic/firmware/pull/9905\r\n- Heltec v4.3: enable LNA by default by @weebl2000 in https://github.com/meshtastic/firmware/pull/9906\r\n- Heltec V4 + TFT expansion kit: rotated MUI by @mverch67 in https://github.com/meshtastic/firmware/pull/9938\r\n- HexDump: Add const to the buf parameter in hexDump. by @fw190d13 in https://github.com/meshtastic/firmware/pull/9944\r\n- Add meshtasticd config metadata by @vidplace7 in https://github.com/meshtastic/firmware/pull/10001\r\n- Exclude accelerometer on new MESHTASTIC_EXCLUDE_ACCELEROMETER flag by @thebentern in https://github.com/meshtastic/firmware/pull/10004\r\n- MUI: WiFi map tile download: heltec V4 adaptations by @mverch67 in https://github.com/meshtastic/firmware/pull/10011\r\n- Mesh-tab wifi map + exclude screen fix by @mverch67 in https://github.com/meshtastic/firmware/pull/10038\r\n- Thinknode_m5 minor fixes by @jp-bennett in https://github.com/meshtastic/firmware/pull/10049\r\n- Add a hardfault handler so it's more obvious when STM32 crashes. by @Stary2001 in https://github.com/meshtastic/firmware/pull/10071\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Add agc reset attempt by @jp-bennett in https://github.com/meshtastic/firmware/pull/8163\r\n- Improved manual build flow to make it easier by @NomDeTom in https://github.com/meshtastic/firmware/pull/8839\r\n- Support mini ePaper S3 Kit by @mverch67 in https://github.com/meshtastic/firmware/pull/9335\r\n- Remove GPS Baudrate locking for Seeed Xiao S3 Kit by @fifieldt in https://github.com/meshtastic/firmware/pull/9374\r\n- Fix heltec v4 tft dependency by @Quency-D in https://github.com/meshtastic/firmware/pull/9507\r\n- Apply SX1262 register 0x8B5 patch for improved GC1109 RX sensitivity by @weebl2000 in https://github.com/meshtastic/firmware/pull/9571\r\n- Hold GC1109 FEM power during deep sleep for LNA RX wake by @weebl2000 in https://github.com/meshtastic/firmware/pull/9572\r\n- Fix some random compiler warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9596\r\n- Add missing openocd_target to custom nrf52 boards by @Stary2001 in https://github.com/meshtastic/firmware/pull/9603\r\n- Fixes on SCD4X admin comands by @oscgonfer in https://github.com/meshtastic/firmware/pull/9607\r\n- Feat/add scd30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9609\r\n- Zero entire public key array instead of only first byte by @weebl2000 in https://github.com/meshtastic/firmware/pull/9619\r\n- Respect DontMqttMeBro flag regardless of channel PSK by @weebl2000 in https://github.com/meshtastic/firmware/pull/9626\r\n- Undefine LED_BUILTIN for Heltec v2 variant by @ericbarch in https://github.com/meshtastic/firmware/pull/9647\r\n- Fix typo in PIN_GPS_SWITCH by @Jorropo in https://github.com/meshtastic/firmware/pull/9648\r\n- Workaround NCP5623 and LP5562 I2C builds by @Jorropo in https://github.com/meshtastic/firmware/pull/9652\r\n- RadioLib edge-triggered interrupts robustness by @compumike in https://github.com/meshtastic/firmware/pull/9658\r\n- Add USB_MODE=1 for Station G2 - Solving all my serial issues. by @h3lix1 in https://github.com/meshtastic/firmware/pull/9660\r\n- Fix detection of SCD30 by checking if the size of the return from a 2 byte register read is correct by @caveman99 in https://github.com/meshtastic/firmware/pull/9664\r\n- Fix/rak3401 button by @LN4CY in https://github.com/meshtastic/firmware/pull/9668\r\n- Undefine LED_BUILTIN for 9m2ibr_aprs_lora_tracker by @mrekin in https://github.com/meshtastic/firmware/pull/9685\r\n- BLE Pairing fix by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9701\r\n- Implement 'agc' reset for SX126x & LR11x0 chip families by @weebl2000 in https://github.com/meshtastic/firmware/pull/9705\r\n- Add explicit dependency on mklittlefs. by @cpatulea in https://github.com/meshtastic/firmware/pull/9708\r\n- Platform: nrf52: Fix typo in BLEDfuSecure filename by @KokoSoft in https://github.com/meshtastic/firmware/pull/9709\r\n- Meshtasticd: Add Luckfox Lyra Hat pinmaps by @vidplace7 in https://github.com/meshtastic/firmware/pull/9730\r\n- Fix WisMesh Tap V2 env mess by @thebentern in https://github.com/meshtastic/firmware/pull/9734\r\n- Hopefully fix remaining cppcheck issues by @caveman99 in https://github.com/meshtastic/firmware/pull/9745\r\n- Add heltec-v4.3 board by @Quency-D in https://github.com/meshtastic/firmware/pull/9753\r\n- Fix RAK4631 Ethernet gateway API connection loss after W5100S brownout by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9754\r\n- Fix Bluetooth on RAK Ethernet Gateway by removing MESHTASTIC_EXCLUDE_… by @thebentern in https://github.com/meshtastic/firmware/pull/9755\r\n- Increase PSRAM malloc threshold from 256 bytes to 2048 bytes by @thebentern in https://github.com/meshtastic/firmware/pull/9758\r\n- Don't launch canned message when waking screen or silencing notification by @jp-bennett in https://github.com/meshtastic/firmware/pull/9762\r\n- Fix nRF52 AsyncUDP multicast TX/RX race on W5100S by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9765\r\n- Fix W5100S socket exhaustion blocking MQTT and additional TCP clients by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9770\r\n- Avoid memory leak when possibly malformed packet is received by @m1nl in https://github.com/meshtastic/firmware/pull/9781\r\n- Add ADS1115 ADC to recognition as used on RAK6421 Hat by @caveman99 in https://github.com/meshtastic/firmware/pull/9790\r\n- Traceroute through MQTT misses uplink node if MQTT is encrypted by @domusonline in https://github.com/meshtastic/firmware/pull/9798\r\n- Improve resource cleanup on connection close (and make server API a unique pointer) by @thebentern in https://github.com/meshtastic/firmware/pull/9799\r\n- Spelling fixes by @ldoolitt in https://github.com/meshtastic/firmware/pull/9801\r\n- Spelling fixes in .md files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9810\r\n- Treat ROUTER_LATE like ROUTER for power management and defaults by @h3lix1 in https://github.com/meshtastic/firmware/pull/9815\r\n- Add ROUTER_LATE use the same rebroadcast rules as ROUTER by @h3lix1 in https://github.com/meshtastic/firmware/pull/9816\r\n- Prevent router-like roles from auto-favoriting DM peers by @h3lix1 in https://github.com/meshtastic/firmware/pull/9821\r\n- Fix(t1000e): reclassify P0.04 as sensor power enable GPIO by @weebl2000 in https://github.com/meshtastic/firmware/pull/9826\r\n- Don't double-blink Thinknode-M1 Power LED while charging by @jp-bennett in https://github.com/meshtastic/firmware/pull/9829\r\n- Debian: Extend sourcedeb cache expiration by @vidplace7 in https://github.com/meshtastic/firmware/pull/9858\r\n- Fix(tlora-pager): Remove SDCARD_USE_SPI1 so SX1262 and SD can share bus by @ndoo in https://github.com/meshtastic/firmware/pull/9870\r\n- Update ESP8266Audio dependency to Meshtastic fork for compatibility by @thebentern in https://github.com/meshtastic/firmware/pull/9872\r\n- Pioarduino Heltec v4: fix build due to LED_BUILTIN compile error. by @cpatulea in https://github.com/meshtastic/firmware/pull/9875\r\n- Fix rak_wismeshtag low‑voltage reboot hang after App configuration by @Ethan-chen1234-zy in https://github.com/meshtastic/firmware/pull/9897\r\n- Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio. by @niklaswall in https://github.com/meshtastic/firmware/pull/9916\r\n- Add new RAK 13302 power curve by @jp-bennett in https://github.com/meshtastic/firmware/pull/9929\r\n- MQTT settings silently fail to persist when broker is unreachable by @rcatal01 in https://github.com/meshtastic/firmware/pull/9934\r\n- Remove early return during scan of BME address for BMP sensors by @NomDeTom in https://github.com/meshtastic/firmware/pull/9935\r\n- Ensure infrastructure role-based minimums are coerced since they don't have scaling by @thebentern in https://github.com/meshtastic/firmware/pull/9937\r\n- Fixes #9792 : Hop with Meshtastic ffff and ?dB is added to missing hop in traceroute by @domusonline in https://github.com/meshtastic/firmware/pull/9945\r\n- Fix NodeInfo suppression logic to ensure suppression only applies to external requests by @thebentern in https://github.com/meshtastic/firmware/pull/9947\r\n- Enable touch-to-backlight on T-Echo (not just T-Echo Plus) by @okturan in https://github.com/meshtastic/firmware/pull/9953\r\n- Fix TFTDisplay::display to align pixels at 32-bit boundary by @notmasteryet in https://github.com/meshtastic/firmware/pull/9956\r\n- Fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users by @NomDeTom in https://github.com/meshtastic/firmware/pull/9958\r\n- Add heltec_mesh_node_t096 board. by @Quency-D in https://github.com/meshtastic/firmware/pull/9960\r\n- Cardputer-Adv I2S sound by @mverch67 in https://github.com/meshtastic/firmware/pull/9963\r\n- Fixes #9850: Double space issue with Cyrillic OLED font by @dev-nightcore in https://github.com/meshtastic/firmware/pull/9971\r\n- Add LED_BUILTIN for variant tlora_v1 by @RobertSasak in https://github.com/meshtastic/firmware/pull/9973\r\n- Add timeout to PPA uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/9989\r\n- Exclude web server, paxcounter and few others from original ESP32 generation to fix IRAM overflow by @thebentern in https://github.com/meshtastic/firmware/pull/10005\r\n- Update External Notifications with a full redo of logic gates by @Xaositek in https://github.com/meshtastic/firmware/pull/10006\r\n- Supporting STM32WL is like squeezing blood from a stone by @Stary2001 in https://github.com/meshtastic/firmware/pull/10015\r\n- Configure NFC pins as GPIO for older bootloaders by @NomDeTom in https://github.com/meshtastic/firmware/pull/10016\r\n- Fix TransmitHistory to improve epoch handling by @thebentern in https://github.com/meshtastic/firmware/pull/10017\r\n- Wio-sdk-wm1110: inherit build_unflags by @vidplace7 in https://github.com/meshtastic/firmware/pull/10034\r\n- ESP32: Take away \"tbeam\" boards PSRAM to reclaim iram by @vidplace7 in https://github.com/meshtastic/firmware/pull/10036\r\n- Set t5s3_epaper_inkhud to `extra` by @vidplace7 in https://github.com/meshtastic/firmware/pull/10037\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update adafruit mpu6050 to v2.2.9 by @app/renovate in https://github.com/meshtastic/firmware/pull/9611\r\n- Update Sensirion Core to v0.7.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9613\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9616\r\n- Update actions/stale action to v10.2.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9669\r\n- Update meshtastic-GxEPD2 digest to c7eb4c3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9694\r\n- Update radiolib to v7.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9695\r\n- Update sensorlib to v0.3.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9727\r\n- Update meshtastic-st7789 digest to 9ee76d6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9729\r\n- Update adafruit mlx90614 to v2.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9756\r\n- Update adafruit_tsl2561 to v1.1.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9757\r\n- Update platformio/espressif32 to v6.13.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9759\r\n- Update platformio/nordicnrf52 to v10.11.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9760\r\n- Update adafruit dps310 to v1.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9763\r\n- Update platformio/ststm32 to v19.5.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9764\r\n- Update adafruit ahtx0 to v2.0.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9766\r\n- Update github artifact actions (major) by @app/renovate in https://github.com/meshtastic/firmware/pull/9767\r\n- Update crazy-max/ghaction-import-gpg action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9787\r\n- Update arduinojson to v6.21.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9788\r\n- Update dorny/test-reporter action to v2.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9796\r\n- Update docker/login-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9806\r\n- Update docker/setup-qemu-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9807\r\n- Update docker/setup-buildx-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9824\r\n- Update docker/build-push-action action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9832\r\n- Update docker/metadata-action action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9833\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9839\r\n- Update meshtastic-esp32_https_server digest to b78f12c by @app/renovate in https://github.com/meshtastic/firmware/pull/9851\r\n- Update meshtastic/device-ui digest to 622b034 by @app/renovate in https://github.com/meshtastic/firmware/pull/9864\r\n- Update GxEPD2 to v1.6.8 by @app/renovate in https://github.com/meshtastic/firmware/pull/9918\r\n- Update pnpm/action-setup action to v5 by @app/renovate in https://github.com/meshtastic/firmware/pull/9926\r\n- Update meshtastic/device-ui digest to f36d2a9 by @app/renovate in https://github.com/meshtastic/firmware/pull/9940\r\n- Update dorny/test-reporter action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9981\r\n- Deps: Cleanup LewisHe library references by @vidplace7 in https://github.com/meshtastic/firmware/pull/10007\r\n- Dependencies: Remove all fuzzy-matches, spot-add renovate by @vidplace7 in https://github.com/meshtastic/firmware/pull/10008\r\n- Update Adafruit_BME680 to v2.0.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10009\r\n- Update meshtastic/device-ui digest to 7b1485b by @app/renovate in https://github.com/meshtastic/firmware/pull/10023\r\n- Renovate: Don't update branches outside the schedule (daily) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10039\r\n- Update meshtastic/device-ui digest to 7b1485b by @app/renovate in https://github.com/meshtastic/firmware/pull/10044\r\n- Update meshtastic/device-ui digest to 1897dd1 by @app/renovate in https://github.com/meshtastic/firmware/pull/10050\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.21.1370b23" + "release_notes": "> [!WARNING]\r\n> Due to resource constraints, the HTTP server is deprecated on original-generation ESP32 devices and should not be relied on going forward. \r\n> Support continues on ESP32-S3 and other newer ESP32 generations.\r\n\r\n## 🚀 Enhancements\r\n\r\n- Add T5-4.7-S3 Epaper Pro support. #6625\r\n- Apply Thailand NBTC 920-925 MHz limits (27 dBm, 10% duty cycle). #9827\r\n- Switch nRF52840 builds to C++17. #9874\r\n- Clean up SEN5X warnings. #9884\r\n- Refactor BaseUI emotes. #9896\r\n- Add spoof detection in `UdpMulticastHandler`. #9905\r\n- Enable LNA by default on Heltec v4.3. #9906\r\n- Rotate MUI for the Heltec V4 + TFT expansion kit. #9938\r\n- Make `hexDump()` take a `const` buffer. #9944\r\n- Add `meshtasticd` config metadata. #10001\r\n- Add `MESHTASTIC_EXCLUDE_ACCELEROMETER`. #10004\r\n- Adapt MUI WiFi map tile downloads for Heltec V4. #10011\r\n- Fix Mesh-tab WiFi map and exclude-screen behavior. #10038\r\n- Include Thinknode M5 minor fixes. #10049\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Remove GPS baudrate locking on the Seeed Xiao S3 kit. #9374\r\n- Fix RAK4631 Ethernet gateway API connection loss after W5100S brownouts. #9754\r\n- Fix W5100S socket exhaustion blocking MQTT and additional TCP clients. #9770\r\n- Fix traceroute over MQTT when the uplink node is encrypted. #9798\r\n- Extend Debian sourcedeb cache expiration. #9858\r\n- Fix T-LoRA Pager SPI bus sharing between SX1262 and the SD card. #9870\r\n- Update `ESP8266Audio` to the Meshtastic fork for compatibility. #9872\r\n- Fix `rak_wismeshtag` low-voltage reboot hangs after app configuration. #9897\r\n- Preserve `pki_encrypted` and `public_key` when relaying UDP multicast packets to radio. #9916\r\n- Add the new RAK 13302 power curve. #9929\r\n- Fix MQTT settings not persisting when the broker is unreachable. #9934\r\n- Fix BMP detection by not returning early during BME address scans. #9935\r\n- Enforce infrastructure-role minimums even when scaling is disabled. #9937\r\n- Fix traceroute hop rendering for `ffff` / unknown-dB hops. #9945\r\n- Fix NodeInfo suppression so it only applies to external requests. #9947\r\n- Enable touch-to-backlight on T-Echo, not just T-Echo Plus. #9953\r\n- Prevent licensed users from rebroadcasting packets to or from unlicensed users. #9958\r\n- Add the `heltec_mesh_node_t096` board. #9960\r\n- Add Cardputer-Adv I2S audio support. #9963\r\n- Fix the Cyrillic OLED double-space issue. #9971\r\n- Add `LED_BUILTIN` for `tlora_v1`. #9973\r\n- Add a timeout for PPA uploads. #9989\r\n- Exclude the web server, Paxcounter, and a few other components on original ESP32 boards to avoid IRAM overflow. #10005\r\n- Rework External Notifications logic. #10006\r\n- Improve STM32WL support. #10015\r\n- Configure NFC pins as GPIO for older bootloaders. #10016\r\n- Fix `TransmitHistory` epoch handling. #10017\r\n- Inherit `build_unflags` for `wio-sdk-wm1110`. #10034\r\n- Remove PSRAM from `tbeam` boards to reclaim IRAM. #10036\r\n- Move `t5s3_epaper_inkhud` to `extra`. #10037\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update `meshtastic-esp32_https_server` to digest `b78f12c`. #9851\r\n- Update `meshtastic/device-ui` through digests `622b034`, `f36d2a9`, `7b1485b`, and `1897dd1`. #9864 #9940 #10023 #10044 #10050\r\n- Update `GxEPD2` to `v1.6.8`. #9918\r\n- Update `pnpm/action-setup` to `v5`. #9926\r\n- Update `dorny/test-reporter` to `v3`. #9981\r\n- Clean up LewisHe library references and dependency matching, and tighten Renovate scheduling. #10007 #10008 #10039\r\n- Update `Adafruit_BME680` to `v2.0.6`. #10009\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.21.1370b23\r\n" }, { "id": "v2.7.20.6658ec2", diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index e6ec807d8..8206e5aaf 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -374,6 +374,7 @@ Durată: %1$s s Ruta trasată spre destinație:\n\n Ruta urmărită înapoi la noi:\n\n + Media de încărcare sistem de cinci minute 24H 1W 2W @@ -542,8 +543,10 @@ Activat Configurație Paxcounter Paxcounter activat + Pragul WiFi RSSI (implicit la -80) Expirat + Valorile mediului utilizează Fahrenheit Nume lung Nume scurt Model hardware @@ -555,10 +558,13 @@ Radiație URL + Nemonitorizată sau infrastructură + Valori dispozitiv Indicatori de mediu Valori putere Arată repere + Ești sigur că vrei să-ți regenerezi cheia privata?\n\nNodurile care ar fi putut schimba anterior chei cu acest modul vor trebui să elimine acel nod şi să schimbe din nou cheile pentru a relua comunicarea securizată. (%1$d online / %2$d afișate / %3$d în total) Meshtastic Avansate @@ -567,6 +573,8 @@ Mesaj + Rata limită depășită. Te rugăm să încerci din nou mai târziu. + Administreaza Layers Hartă Mesajele provenite de la o un gateway public de internet sunt redirecționate către rețeaua locală. Datorită politicii de zero salturi, traficul provenit de la serverul MQTT implicit nu se va propaga mai departe de acest dispozitiv. Activează/dezactivează modulul de telemetrie al dispozitivului pentru a trimite metrici către rețeaua mesh. Acestea sunt valori nominale. Rețelele mesh congestionate se vor scala automat la intervale mai lungi, în funcție de numărul de noduri online. O oră @@ -574,7 +582,9 @@ 24 Ore 48 Ore + Stabil Actualizare eșuată + Conectarea la dispozitiv (încercarea %1$d/%2$d)... Nesetat %1$d oră diff --git a/fastlane/metadata/android/ro-RO/changelogs/default.txt b/fastlane/metadata/android/ro-RO/changelogs/default.txt index 0553de284..b254b55b8 100644 --- a/fastlane/metadata/android/ro-RO/changelogs/default.txt +++ b/fastlane/metadata/android/ro-RO/changelogs/default.txt @@ -1 +1 @@ -For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +Pentru note detaliate pentru versiuni, vizitați: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file diff --git a/fastlane/metadata/android/ro-RO/short_description.txt b/fastlane/metadata/android/ro-RO/short_description.txt index e3f0988db..f6c7d5664 100644 --- a/fastlane/metadata/android/ro-RO/short_description.txt +++ b/fastlane/metadata/android/ro-RO/short_description.txt @@ -1 +1 @@ -The official app for Meshtastic, an open-source, off-grid, mesh radio. \ No newline at end of file +Aplicația oficială pentru Meshtastic, un radio open, off-grid, mess. \ No newline at end of file From 087fbbfb457af3388d30fedae69551252bdb58bf Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:11:42 -0500 Subject: [PATCH 041/114] fix(build): overhaul R8 rules and DRY up build-logic conventions (#5109) --- .skills/code-review/SKILL.md | 4 + .skills/implement-feature/SKILL.md | 4 + app/proguard-rules.pro | 84 +++++++++---------- .../AndroidApplicationConventionPlugin.kt | 5 -- .../kotlin/AndroidLibraryConventionPlugin.kt | 5 -- .../meshtastic/buildlogic/KotlinAndroid.kt | 49 ++++++----- desktop/README.md | 10 ++- desktop/proguard-rules.pro | 8 ++ docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md | 28 +++---- 9 files changed, 99 insertions(+), 98 deletions(-) diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md index dce08761d..de8c93c88 100644 --- a/.skills/code-review/SKILL.md +++ b/.skills/code-review/SKILL.md @@ -61,6 +61,10 @@ When reviewing code, meticulously verify the following categories. Flag any devi - [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. - [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues. +### 8. ProGuard / R8 Rules +- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned. +- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed. + ## Review Output Guidelines 1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern. 2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio"). diff --git a/.skills/implement-feature/SKILL.md b/.skills/implement-feature/SKILL.md index 1efa3caa0..0e76b30e6 100644 --- a/.skills/implement-feature/SKILL.md +++ b/.skills/implement-feature/SKILL.md @@ -35,3 +35,7 @@ A step-by-step workflow for implementing a new feature in the Meshtastic-Android ```bash ./gradlew spotlessCheck detekt assembleDebug test allTests ``` +- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds: + ```bash + ./gradlew assembleFdroidRelease :desktop:runRelease + ``` diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 995f659ba..7feaa9217 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,61 +1,61 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +# ============================================================================ +# Meshtastic Android — ProGuard / R8 rules for release minification +# ============================================================================ +# Open-source project: obfuscation is disabled. We rely on tree-shaking and +# code optimization for APK size reduction. +# ============================================================================ -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +# ---- General ---------------------------------------------------------------- -# Uncomment this to preserve the line number information for -# debugging stack traces. +# Preserve line numbers for meaningful crash stack traces -keepattributes SourceFile,LineNumberTable -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile +# Open-source — no need to obfuscate +-dontobfuscate -# Room KMP: preserve generated database constructor (required for R8/ProGuard) --keep class * extends androidx.room.RoomDatabase { (); } +# ---- Networking (transitive references from Ktor) --------------------------- -# Needed for protobufs --keep class com.google.protobuf.** { *; } --keep class org.meshtastic.proto.** { *; } - -# Networking -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** -# ? --dontwarn java.lang.reflect.** --dontwarn com.google.errorprone.annotations.** +# ---- Wire Protobuf ---------------------------------------------------------- -# Our app is opensource no need to obsfucate --dontobfuscate --optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable +# Wire-generated proto message classes (accessed via ADAPTER companion reflection) +-keep class org.meshtastic.proto.** { *; } -# Koin DI: prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException +# ---- Room KMP (room3) ------------------------------------------------------ + +# Preserve generated database constructors (Room uses reflection to instantiate) +-keep class * extends androidx.room3.RoomDatabase { (); } + +# ---- Koin DI ---------------------------------------------------------------- + +# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException # replacing Koin's InstanceCreationException in stack traces, making crashes undiagnosable). -keep class org.koin.core.error.** { *; } -# R8 optimization for Kotlin null checks (AGP 9.0+) --processkotlinnullchecks remove +# ---- Compose Multiplatform -------------------------------------------------- -# Compose Multiplatform resources: keep the resource library internals and generated Res -# accessor classes so R8 does not tree-shake the resource loading infrastructure. -# Without these rules the fdroid flavor (which has fewer transitive Compose dependencies -# than google) crashes at startup with a misleading URLDecodeException due to R8 -# exception-class merging (see Koin keep rule above). +# Keep resource library internals and generated Res accessor classes so R8 does +# not tree-shake the resource loading infrastructure. Without these rules the +# fdroid flavor crashes at startup with a misleading URLDecodeException due to +# R8 exception-class merging. -keep class org.jetbrains.compose.resources.** { *; } -keep class org.meshtastic.core.resources.** { *; } -# Nordic BLE --dontwarn no.nordicsemi.kotlin.ble.environment.android.mock.** --keep class no.nordicsemi.kotlin.ble.environment.android.mock.** { *; } --keep class no.nordicsemi.kotlin.ble.environment.android.compose.** { *; } +# Compose Animation: prevent R8 from merging animation spec classes (easing +# curves, transition specs, Animatable internals) which can cause animations to +# silently snap in release builds. +# +# -keep prevents class merging (EnterTransition/ExitTransition into *Impl, +# VectorizedSpringSpec/TweenSpec elimination, etc.). +# allowshrinking lets R8 remove genuinely unreachable classes (e.g. +# SharedTransition APIs, RepeatableSpec — unused by this app). Verified via +# dex analysis: 278 classes survive in release vs 139 without this rule; +# all actively used classes (AnimatedVisibility, Crossfade, SpringSpec, +# TweenSpec, EnterTransition, ExitTransition, etc.) are preserved. +# allowobfuscation is moot (-dontobfuscate is set above) but explicit for +# clarity. +# The ** wildcard is recursive and covers animation.core.* sub-packages. +-keep,allowshrinking,allowobfuscation class androidx.compose.animation.** { *; } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index fd432a1fa..cc53f27ec 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -40,14 +40,9 @@ class AndroidApplicationConventionPlugin : Plugin { configureKotlinAndroid(this) defaultConfig { - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true } - testOptions { - animationsDisabled = true - unitTests.isReturnDefaultValues = true - } buildTypes { getByName("release") { diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index cf3ae81db..68771d24a 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -38,11 +38,6 @@ class AndroidLibraryConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testOptions { - animationsDisabled = true - unitTests.isReturnDefaultValues = true - } defaultConfig { // When flavorless modules depend on flavored modules (like :core:data), diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index 580db4c4b..bcc6d0207 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -44,19 +44,19 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) { compileSdk = compileSdkVersion defaultConfig.minSdk = minSdkVersion + defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" if (this is ApplicationExtension) { defaultConfig.targetSdk = targetSdkVersion } - val javaVersion = if (project.name in listOf("api", "model", "proto")) { - JavaVersion.VERSION_17 - } else { - JavaVersion.VERSION_21 - } + val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21 compileOptions.sourceCompatibility = javaVersion compileOptions.targetCompatibility = javaVersion + testOptions.animationsDisabled = true + testOptions.unitTests.isReturnDefaultValues = true + // Exclude duplicate META-INF license files shipped by JUnit Platform JARs packaging.resources.excludes.addAll( listOf( @@ -190,11 +190,25 @@ internal fun Project.configureKotlinJvm() { configureKotlin() } +/** Modules published for external consumers — use Java 17 for broader compatibility. */ +private val PUBLISHED_MODULES = setOf("api", "model", "proto") + +/** Compiler args shared across all Kotlin targets (JVM, Android, iOS, etc.). */ +private val SHARED_COMPILER_ARGS = listOf( + "-opt-in=kotlin.uuid.ExperimentalUuidApi", + "-opt-in=kotlin.time.ExperimentalTime", + "-Xexpect-actual-classes", + "-Xcontext-parameters", + "-Xannotation-default-target=param-property", + "-Xskip-prerelease-check", +) + /** Configure base Kotlin options */ private inline fun Project.configureKotlin() { + val isPublishedModule = project.name in PUBLISHED_MODULES + extensions.configure { - val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21 - val isPublishedModule = project.name in listOf("api", "model", "proto") + val javaVersion = if (isPublishedModule) 17 else 21 // Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older environments), // and Java 21 for the rest of the app. jvmToolchain(javaVersion) @@ -208,14 +222,7 @@ private inline fun Project.configureKotlin() { if (!isPublishedModule) { freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") } - freeCompilerArgs.addAll( - "-opt-in=kotlin.uuid.ExperimentalUuidApi", - "-opt-in=kotlin.time.ExperimentalTime", - "-Xexpect-actual-classes", - "-Xcontext-parameters", - "-Xannotation-default-target=param-property", - "-Xskip-prerelease-check", - ) + freeCompilerArgs.addAll(SHARED_COMPILER_ARGS) if (isJvmTarget) { freeCompilerArgs.add("-jvm-default=no-compatibility") } @@ -230,21 +237,13 @@ private inline fun Project.configureKotlin() { tasks.withType().configureEach { compilerOptions { - val isPublishedModule = project.name in listOf("api", "model", "proto") jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21) allWarningsAsErrors.set(warningsAsErrors) if (!isPublishedModule) { freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") } - freeCompilerArgs.addAll( - "-opt-in=kotlin.uuid.ExperimentalUuidApi", - "-opt-in=kotlin.time.ExperimentalTime", - "-Xexpect-actual-classes", - "-Xcontext-parameters", - "-Xannotation-default-target=param-property", - "-Xskip-prerelease-check", - "-jvm-default=no-compatibility", - ) + freeCompilerArgs.addAll(SHARED_COMPILER_ARGS) + freeCompilerArgs.add("-jvm-default=no-compatibility") } } } diff --git a/desktop/README.md b/desktop/README.md index 129f49e94..491e9fe68 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -25,14 +25,18 @@ A Compose Desktop application target — the first full non-Android target for t ## ProGuard / Minification -Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source. +Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source. Rules are aligned with the Android R8 rules in `app/proguard-rules.pro` — both targets share the same anti-class-merging philosophy. **Configuration:** - `build.gradle.kts` — `buildTypes.release.proguard` block enables ProGuard with `optimize.set(true)` and `obfuscate.set(false)`. -- `proguard-rules.pro` — Comprehensive keep-rules for all reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources). +- `proguard-rules.pro` — Keep-rules for reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP `androidx.room3`, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources) and an anti-merge rule for Compose animation classes. + +**Key rules:** +- **Compose animation anti-merge** (`-keep,allowshrinking,allowobfuscation class androidx.compose.animation.** { *; }`) — Prevents ProGuard's optimizer from merging animation class hierarchies (e.g. `EnterTransition`/`ExitTransition` into `*Impl`), which causes animations to silently snap. Same rule as Android. +- **Room KMP** — Uses `androidx.room3` package path (Room KMP 3.x). **Troubleshooting ProGuard issues:** -- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro`. +- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro` **and** the corresponding rule in `app/proguard-rules.pro` to keep both targets aligned. - To debug which classes ProGuard removes, temporarily add `-printusage proguard-usage.txt` to the rules file and inspect the output in `desktop/proguard-usage.txt`. - To see the full mapping of optimizations applied, add `-printseeds proguard-seeds.txt`. - Run `./gradlew :desktop:runRelease` for a quick smoke-test of the minified app before packaging. diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index a73c347d1..b4e6cc451 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -147,6 +147,14 @@ -keep class org.jetbrains.compose.resources.** { *; } -keep class org.meshtastic.core.resources.** { *; } +# ---- Compose Animation (anti-merge) ---------------------------------------- + +# Prevent ProGuard from merging animation spec class hierarchies (same issue +# as R8 on Android — EnterTransition/ExitTransition merged into *Impl, +# VectorizedSpringSpec/TweenSpec eliminated). allowshrinking lets ProGuard +# remove genuinely unreachable classes. +-keep,allowshrinking,allowobfuscation class androidx.compose.animation.** { *; } + # ---- AboutLibraries --------------------------------------------------------- -keep class com.mikepenz.aboutlibraries.** { *; } diff --git a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md index 17b152f4a..5898f7f94 100644 --- a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md +++ b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md @@ -129,27 +129,17 @@ kotlin { ### Example: Adding Android-specific test config -**Pattern:** Add to `AndroidLibraryConventionPlugin.kt`: +**Pattern:** Test options (`animationsDisabled`, `testInstrumentationRunner`, `unitTests.isReturnDefaultValues`) are centralized in `configureKotlinAndroid()` via `CommonExtension`, so they apply to both app and library modules automatically. To add new test config, update `KotlinAndroid.kt::configureKotlinAndroid()`: ```kotlin -extensions.configure { - configureKotlinAndroid(this) - testOptions.apply { - animationsDisabled = true - // NEW: Android-specific test config - unitTests.isIncludeAndroidResources = true - } -} -``` - -**Alternative:** If it applies to both app and library, consider extracting a function: - -```kotlin -internal fun Project.configureAndroidTestOptions() { - extensions.configure { - testOptions.apply { +internal fun Project.configureKotlinAndroid( + commonExtension: CommonExtension<*, *, *, *, *, *>, +) { + commonExtension.apply { + testOptions { animationsDisabled = true - // Shared test options + unitTests.isReturnDefaultValues = true + // NEW: Add shared test options here } } } @@ -177,6 +167,8 @@ internal fun Project.configureAndroidTestOptions() { | `AndroidApplicationFlavorsConventionPlugin` ≈ `AndroidLibraryFlavorsConventionPlugin` | **Kept Separate** | Different extension types; small duplication; explicit intent | | `configureKmpTestDependencies()` (7 modules) | **Consolidated** | Large duplication; single source of truth; all KMP modules benefit | | `jvmAndroidMain` hierarchy setup (4 modules) | **Consolidated** | Shared KMP hierarchy pattern; avoids manual `dependsOn(...)` edges and hierarchy warnings | +| `PUBLISHED_MODULES` set (4 usages) | **Consolidated** | Was repeated as `listOf(...)` in 4 places; now a single `setOf(...)` constant in `KotlinAndroid.kt` | +| `SHARED_COMPILER_ARGS` list (2 code paths) | **Consolidated** | Eliminates duplicated `-opt-in` flags between KMP target compilations and `KotlinCompile` task configuration | ## Testing Convention Changes From 61f90352c417ef22629f6ab78f35d9e638ad6ca3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:15:52 -0500 Subject: [PATCH 042/114] chore(deps): update agp to v9.2.0-rc01 (#5107) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 52d30d1ea..303bb7744 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ xmlutil = "0.91.3" # Android -agp = "9.2.0-alpha08" +agp = "9.2.0-rc01" appcompat = "1.7.1" accompanist = "0.37.3" From 75e2177da715dd4337218bda4aba3622a943a08a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:16:04 -0500 Subject: [PATCH 043/114] chore(deps): update com.android.tools:common to v32.1.1 (#5108) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 303bb7744..1ae325188 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -236,7 +236,7 @@ vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ve # Build Logic android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" } -android-tools-common = { module = "com.android.tools:common", version = "32.1.0" } +android-tools-common = { module = "com.android.tools:common", version = "32.1.1" } androidx-room-gradlePlugin = { module = "androidx.room3:room3-gradle-plugin", version.ref = "room" } compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } compose-multiplatform-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" } From 8a06157ff4b08b08dffe9ef11e92ecb61f29357e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:59:19 -0500 Subject: [PATCH 044/114] docs: remove agent cruft, condense and validate remaining docs (#5110) --- .github/workflows/pull-request.yml | 4 - .skills/code-review/SKILL.md | 17 +- .skills/project-overview/SKILL.md | 42 +-- .skills/testing-ci/SKILL.md | 8 - AGENTS.md | 6 +- SOUL.md | 31 -- conductor/code_styleguides/general.md | 23 -- conductor/index.md | 14 - conductor/product-guidelines.md | 19 - conductor/product.md | 26 -- conductor/tech-stack.md | 38 -- conductor/tracks.md | 5 - conductor/workflow.md | 333 ------------------ docs/decisions/README.md | 15 - docs/decisions/architecture-review-2026-03.md | 256 -------------- .../navigation3-api-alignment-2026-03.md | 124 ------- docs/decisions/navigation3-parity-2026-03.md | 167 --------- docs/kmp-status.md | 26 +- docs/roadmap.md | 2 +- docs/testing/baseline_coverage.md | 6 - docs/testing/final_coverage.md | 18 - 21 files changed, 30 insertions(+), 1150 deletions(-) delete mode 100644 SOUL.md delete mode 100644 conductor/code_styleguides/general.md delete mode 100644 conductor/index.md delete mode 100644 conductor/product-guidelines.md delete mode 100644 conductor/product.md delete mode 100644 conductor/tech-stack.md delete mode 100644 conductor/tracks.md delete mode 100644 conductor/workflow.md delete mode 100644 docs/decisions/README.md delete mode 100644 docs/decisions/architecture-review-2026-03.md delete mode 100644 docs/decisions/navigation3-api-alignment-2026-03.md delete mode 100644 docs/decisions/navigation3-parity-2026-03.md delete mode 100644 docs/testing/baseline_coverage.md delete mode 100644 docs/testing/final_coverage.md diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 22a611576..209d6e35c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -3,10 +3,6 @@ name: Pull Request CI on: pull_request: branches: [ main ] - paths-ignore: - - '**/*.md' - - 'docs/**' - - '.gitignore' permissions: contents: read diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md index de8c93c88..b39e2d0d9 100644 --- a/.skills/code-review/SKILL.md +++ b/.skills/code-review/SKILL.md @@ -1,16 +1,7 @@ # Skill: Code Review ## Description -Perform comprehensive and precise code reviews for the `Meshtastic-Android` project. This skill ensures that incoming changes adhere strictly to the project's architecture guidelines, Kotlin Multiplatform (KMP) conventions, Modern Android Development (MAD) standards, and Jetpack Compose Multiplatform (CMP) best practices. - -## Context & Prerequisites -The `Meshtastic-Android` codebase is a highly modernized Kotlin Multiplatform (KMP) application designed for off-grid, decentralized mesh networks. -- **Language:** Kotlin (primary), JDK 21 required. -- **Architecture:** KMP core with Android and Desktop host shells. -- **UI:** Jetpack Compose Multiplatform (CMP) and Material 3 Adaptive. -- **Navigation:** JetBrains Navigation 3 (Scene-based). -- **DI:** Koin Annotations (with K2 compiler plugin). -- **Async & I/O:** Kotlin Coroutines, Flow, Okio, Ktor. +Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices. ## Code Review Checklist @@ -64,9 +55,3 @@ When reviewing code, meticulously verify the following categories. Flag any devi ### 8. ProGuard / R8 Rules - [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned. - [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed. - -## Review Output Guidelines -1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern. -2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio"). -3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous. -4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions. diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md index 6df668bf2..d7d6af473 100644 --- a/.skills/project-overview/SKILL.md +++ b/.skills/project-overview/SKILL.md @@ -1,21 +1,13 @@ # Skill: Project Overview & Codebase Map ## Description -High-level project context, module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android. +Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android. -## 1. Project Vision & Architecture -Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling expansion to iOS and Desktop while maintaining a high-performance native Android experience. +- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26. +- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics) +- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`. -- **Language:** Kotlin (primary), AIDL. -- **Build System:** Gradle (Kotlin DSL). JDK 21 is REQUIRED. -- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0). -- **Flavors:** - - `fdroid`: Open source only, no tracking/analytics. - - `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production"). -- **KMP Modules:** Most `core:*` modules declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all. -- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. - -## 2. Codebase Map +## Codebase Map | Directory | Description | | :--- | :--- | @@ -47,13 +39,13 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. | | `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | | `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. | -| `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app. See `core/api/README.md` for the current integration guide. | +| `mesh_service_example/` | **DEPRECATED.** Legacy sample app; not yet removed. See `core/api/README.md` for the current integration guide. | -## 3. Namespacing +## Namespacing - **Standard:** Use the `org.meshtastic.*` namespace for all code. - **Legacy:** Maintain the `com.geeksville.mesh` Application ID. -## 4. Environment Setup +## Environment Setup 1. **JDK 21 MUST be used** to prevent Gradle sync/build failures. 2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`: ```properties @@ -62,7 +54,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec datadogClientToken=dummy_token ``` -## 5. Workspace Bootstrap (MUST run before any build) +## Workspace Bootstrap (MUST run before any build) Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you. 1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it: @@ -81,17 +73,7 @@ Agents **MUST** perform these steps automatically at the start of every session git submodule update --init ``` -## 6. Troubleshooting +## Troubleshooting - **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts. -- **Missing Secrets:** Check `local.properties` (see Environment Setup above). -- **JDK Version:** JDK 21 is required. -- **SDK location not found:** See Workspace Bootstrap step 1 above. -- **Proto generation failures:** See Workspace Bootstrap step 2 above. -- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist. -- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`). - -## Reference Anchors -- **KMP Migration Status:** `docs/kmp-status.md` -- **Roadmap:** `docs/roadmap.md` -- **Architecture Decision Records:** `docs/decisions/` -- **Version Catalog:** `gradle/libs.versions.toml` +- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist. +- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`. diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 586c1ef9c..0dca01eb6 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -86,11 +86,3 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p - **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.). - **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone. -## 5) Shell & Tooling Conventions -- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt. -- **Text Search:** Prefer `rg` (ripgrep) over `grep` or `find` for fast text searching across the codebase. - -## 6) Agent/Developer Guidance -- Start with the smallest set that validates your touched area. -- If unable to run full validation locally, report exactly what ran and what remains. -- Keep documentation synced in `AGENTS.md` and `.skills/` directories. diff --git a/AGENTS.md b/AGENTS.md index 73d29f2b9..9fcc166b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes - `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions. - `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly. -Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md` as needed. +Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed. @@ -61,4 +61,8 @@ Do NOT duplicate content into agent-specific files. When you modify architecture - **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety. - **CMP Over Android:** Use `compose-multiplatform` constraints (e.g., no float formatting in `stringResource`). - **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist. +- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution. +- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them. +- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules. +- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change. diff --git a/SOUL.md b/SOUL.md deleted file mode 100644 index 45924b40f..000000000 --- a/SOUL.md +++ /dev/null @@ -1,31 +0,0 @@ -# Meshtastic-Android: AI Agent Soul (SOUL.md) - -This file defines the personality, values, and behavioral framework of the AI agent for this repository. - -## 1. Core Identity -I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-Android codebase while maintaining its integrity as a secure, decentralized communication tool. I am not just a "helpful assistant"; I am a senior peer programmer who takes ownership of the technical stack. - -## 2. Core Truths & Values -- **Privacy is Paramount:** Meshtastic is used for off-grid, often sensitive communication. I treat user data, location info, and cryptographic keys with extreme caution. I will never suggest logging PII or secrets. -- **Code is a Liability:** I prefer simple, readable code over clever abstractions. I remove dead code and minimize dependencies wherever possible. -- **Decentralization First:** I prioritize architectural patterns that support offline-first and peer-to-peer logic. -- **MAD & KMP are the Standard:** Modern Android Development (Compose, Koin, Coroutines) and Kotlin Multiplatform are not suggestions; they are the foundation. I resist introducing legacy patterns unless absolutely required for OS compatibility. - -## 3. Communication Style (The "Vibe") -- **Direct & Concise:** I skip the fluff. I provide technical rationale first. -- **Opinionated but Grounded:** I provide clear technical recommendations based on established project conventions. -- **Action-Oriented:** I don't just "talk" about code; I implement, test, and format it. - -## 4. Operational Boundaries -- **Zero Lint Tolerance (for code changes):** I consider a coding task incomplete if `detekt` fails or `spotlessCheck` is not passing for touched modules. -- **Test-Driven Execution (where feasible):** For bug fixes, I should reproduce the issue with a test before fixing it when practical. For new features, I should add appropriate verification logic. -- **Dependency Discipline:** I never add a library without checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity. -- **No Hardcoded Strings:** I will refuse to add hardcoded UI strings, strictly adhering to the `:core:resources` KMP resource system. - -## 5. Evolution -I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers. - -For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth. -For implementation recipes and verification scope, I use `.skills/` directory. - - diff --git a/conductor/code_styleguides/general.md b/conductor/code_styleguides/general.md deleted file mode 100644 index dfcc793f4..000000000 --- a/conductor/code_styleguides/general.md +++ /dev/null @@ -1,23 +0,0 @@ -# General Code Style Principles - -This document outlines general coding principles that apply across all languages and frameworks used in this project. - -## Readability -- Code should be easy to read and understand by humans. -- Avoid overly clever or obscure constructs. - -## Consistency -- Follow existing patterns in the codebase. -- Maintain consistent formatting, naming, and structure. - -## Simplicity -- Prefer simple solutions over complex ones. -- Break down complex problems into smaller, manageable parts. - -## Maintainability -- Write code that is easy to modify and extend. -- Minimize dependencies and coupling. - -## Documentation -- Document *why* something is done, not just *what*. -- Keep documentation up-to-date with code changes. diff --git a/conductor/index.md b/conductor/index.md deleted file mode 100644 index 3a362bc99..000000000 --- a/conductor/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# Project Context - -## Definition -- [Product Definition](./product.md) -- [Product Guidelines](./product-guidelines.md) -- [Tech Stack](./tech-stack.md) - -## Workflow -- [Workflow](./workflow.md) -- [Code Style Guides](./code_styleguides/) - -## Management -- [Tracks Registry](./tracks.md) -- [Tracks Directory](./tracks/) \ No newline at end of file diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md deleted file mode 100644 index b54944fea..000000000 --- a/conductor/product-guidelines.md +++ /dev/null @@ -1,19 +0,0 @@ -# Product Guidelines - -## Brand Voice and Tone -- **Technical yet Accessible:** Communicate complex networking and hardware concepts clearly without being overly academic. -- **Reliable and Authoritative:** The app is a utility for critical, off-grid communication. Language should convey stability and safety. -- **Community-Oriented:** Encourage open-source participation and community support. - -## UX Principles -- **Offline-First:** Assume the user has no cellular or Wi-Fi connection. All core functions must work locally via the mesh network. -- **Adaptive Layouts:** Support multiple form factors seamlessly (phones, tablets, desktop) using Material 3 Adaptive Scaffold principles. -- **Information Density:** Give power users access to detailed metrics (SNR, battery, hop limits) without overwhelming beginners. Use progressive disclosure. - -## Prose Style -- **Clarity over cleverness:** Use plain English. -- **Action-oriented:** Button labels and prompts should start with strong verbs (e.g., "Send", "Connect", "Export"). -- **Consistent Terminology:** - - Use "Node" for devices on the network. - - Use "Channel" for communication groups. - - Use "Direct Message" for 1-to-1 communication. \ No newline at end of file diff --git a/conductor/product.md b/conductor/product.md deleted file mode 100644 index edfac5083..000000000 --- a/conductor/product.md +++ /dev/null @@ -1,26 +0,0 @@ -# Initial Concept -A tool for using Android with open-source mesh radios. - -# Product Guide - -## Overview -Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facilitate communication over off-grid, decentralized mesh networks using open-source hardware radios. - -## Target Audience -- Off-grid communication enthusiasts and hobbyists -- Outdoor adventurers needing reliable communication without cellular networks -- Emergency response and disaster relief teams - -## Core Features -- Direct communication with Meshtastic hardware (via BLE, USB, TCP, MQTT) -- Decentralized text messaging across the mesh network -- Unified cross-platform notifications for messages and node events -- Adaptive node and contact management -- Offline map rendering and device positioning -- Device configuration and firmware updates -- Unified cross-platform debugging and packet inspection - -## Key Architecture Goals -- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS) -- Ensure offline-first functionality and resilient data persistence (Room 3 KMP) -- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform \ No newline at end of file diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md deleted file mode 100644 index 75237887b..000000000 --- a/conductor/tech-stack.md +++ /dev/null @@ -1,38 +0,0 @@ -# Tech Stack - -## Programming Language -- **Kotlin Multiplatform (KMP):** The core logic is shared across Android, Desktop, and iOS using `commonMain`. - -## Frontend Frameworks -- **Compose Multiplatform:** Shared UI layer for rendering on Android and Desktop. -- **Jetpack Compose:** Used where platform-specific UI (like charts or permissions) is necessary on Android. - -## Background & Services -- **Platform Services:** Core service orchestrations and background work are abstracted into `core:service` to maximize logic reuse across targets, using platform-specific implementations (e.g., WorkManager/Service on Android) only where necessary. - -## Architecture -- **MVI / Unidirectional Data Flow:** Shared view models using the multiplatform `androidx.lifecycle.ViewModel`. -- **JetBrains Navigation 3:** Multiplatform fork for state-based, compose-first navigation without relying on `NavController`. Navigation graphs are decoupled and extracted into their respective `feature:*` modules, allowing a thinned out root `app` module. - -## Dependency Injection -- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt. - -## Database & Storage -- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and platform-appropriate SQLite drivers (e.g., `BundledSQLiteDriver` for JVM/Desktop, Framework driver for Android). -- **Jetpack DataStore:** Shared preferences. - -## Networking & Transport -- **Ktor:** Multiplatform HTTP client for web services and TCP streaming. -- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS). -- **jSerialComm:** Cross-platform Java library used for direct Serial/USB communication with Meshtastic devices on the Desktop (JVM) target. -- **KMQTT:** Kotlin Multiplatform MQTT client and broker used for MQTT transport, replacing the Android-only Paho library. -- **Coroutines & Flows:** For asynchronous programming and state management. - -## Testing (KMP) -- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`. -- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows. -- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`. -- **Platform-Specific Verification:** Use **Robolectric** on the Android host target to verify KMP modules that interact with Android framework components (like `Uri` or `Room`). -- **Subclassing Pattern:** Maintain a unified test suite by defining abstract base tests in `commonTest` and platform-specific subclasses in `jvmTest` and `androidHostTest` for initialization (e.g., calling `setupTestContext()`). -- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates. -- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios. \ No newline at end of file diff --git a/conductor/tracks.md b/conductor/tracks.md deleted file mode 100644 index 0b5c54e3d..000000000 --- a/conductor/tracks.md +++ /dev/null @@ -1,5 +0,0 @@ -# Project Tracks - -This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. - ---- diff --git a/conductor/workflow.md b/conductor/workflow.md deleted file mode 100644 index 6f9cfd8fc..000000000 --- a/conductor/workflow.md +++ /dev/null @@ -1,333 +0,0 @@ -# Project Workflow - -## Guiding Principles - -1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md` -2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation -3. **Test-Driven Development:** Write unit tests before implementing functionality -4. **High Code Coverage:** Aim for >80% code coverage for all modules -5. **User Experience First:** Every decision should prioritize user experience -6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution. - -## Task Workflow - -All tasks follow a strict lifecycle: - -### Standard Task Workflow - -1. **Select Task:** Choose the next available task from `plan.md` in sequential order - -2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]` - -3. **Write Failing Tests (Red Phase):** - - Create a new test file for the feature or bug fix. - - Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task. - - **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests. - -4. **Implement to Pass Tests (Green Phase):** - - Write the minimum amount of application code necessary to make the failing tests pass. - - Run the test suite again and confirm that all tests now pass. This is the "Green" phase. - -5. **Refactor (Optional but Recommended):** - - With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior. - - Rerun tests to ensure they still pass after refactoring. - -6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like: - ```bash - pytest --cov=app --cov-report=html - ``` - Target: >80% coverage for new code. The specific tools and commands will vary by language and framework. - -7. **Document Deviations:** If implementation differs from tech stack: - - **STOP** implementation - - Update `tech-stack.md` with new design - - Add dated note explaining the change - - Resume implementation - -8. **Commit Code Changes:** - - Stage all code changes related to the task. - - Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`. - - Perform the commit. - -9. **Attach Task Summary with Git Notes:** - - **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`). - - **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change. - - **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit. - ```bash - # The note content from the previous step is passed via the -m flag. - git notes add -m "" - ``` - -10. **Get and Record Task Commit SHA:** - - **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash. - - **Step 10.2: Write Plan:** Write the updated content back to `plan.md`. - -11. **Commit Plan Update:** - - **Action:** Stage the modified `plan.md` file. - - **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`). - -### Phase Completion Verification and Checkpointing Protocol - -**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`. - -1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun. - -2. **Ensure Test Coverage for Phase Changes:** - - **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit. - - **Step 2.2: List Changed Files:** Execute `git diff --name-only HEAD` to get a precise list of all files modified during this phase. - - **Step 2.3: Verify and Create Tests:** For each file in the list: - - **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`). - - For each remaining code file, verify a corresponding test file exists. - - If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`). - -3. **Execute Automated Tests with Proactive Debugging:** - - Before execution, you **must** announce the exact shell command you will use to run the tests. - - **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`" - - Execute the announced command. - - If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance. - -4. **Propose a Detailed, Actionable Manual Verification Plan:** - - **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase. - - You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes. - - The plan you present to the user **must** follow this format: - - **For a Frontend Change:** - ``` - The automated tests have passed. For manual verification, please follow these steps: - - **Manual Verification Steps:** - 1. **Start the development server with the command:** `npm run dev` - 2. **Open your browser to:** `http://localhost:3000` - 3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly. - ``` - - **For a Backend Change:** - ``` - The automated tests have passed. For manual verification, please follow these steps: - - **Manual Verification Steps:** - 1. **Ensure the server is running.** - 2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'` - 3. **Confirm that you receive:** A JSON response with a status of `201 Created`. - ``` - -5. **Await Explicit User Feedback:** - - After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**" - - **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation. - -6. **Create Checkpoint Commit:** - - Stage all changes. If no changes occurred in this step, proceed with an empty commit. - - Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`). - -7. **Attach Auditable Verification Report using Git Notes:** - - **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation. - - **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit. - -8. **Get and Record Phase Checkpoint SHA:** - - **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`). - - **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: ]`. - - **Step 8.3: Write Plan:** Write the updated content back to `plan.md`. - -9. **Commit Plan Update:** - - **Action:** Stage the modified `plan.md` file. - - **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '' as complete`. - -10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note. - -### Quality Gates - -Before marking any task complete, verify: - -- [ ] All tests pass -- [ ] Code coverage meets requirements (>80%) -- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`) -- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc) -- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types) -- [ ] No linting or static analysis errors (using the project's configured tools) -- [ ] Works correctly on mobile (if applicable) -- [ ] Documentation updated if needed -- [ ] No security vulnerabilities introduced - -## Development Commands - -**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.** - -### Setup -```bash -# Example: Commands to set up the development environment (e.g., install dependencies, configure database) -# e.g., for a Node.js project: npm install -# e.g., for a Go project: go mod tidy -``` - -### Daily Development -```bash -# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format) -# e.g., for a Node.js project: npm run dev, npm test, npm run lint -# e.g., for a Go project: go run main.go, go test ./..., go fmt ./... -``` - -### Before Committing -```bash -# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests) -# e.g., for a Node.js project: npm run check -# e.g., for a Go project: make check (if a Makefile exists) -``` - -## Testing Requirements - -### Unit Testing -- Every module must have corresponding tests. -- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach). -- Mock external dependencies. -- Test both success and failure cases. - -### Integration Testing -- Test complete user flows -- Verify database transactions -- Test authentication and authorization -- Check form submissions - -### Mobile Testing -- Test on actual iPhone when possible -- Use Safari developer tools -- Test touch interactions -- Verify responsive layouts -- Check performance on 3G/4G - -## Code Review Process - -### Self-Review Checklist -Before requesting review: - -1. **Functionality** - - Feature works as specified - - Edge cases handled - - Error messages are user-friendly - -2. **Code Quality** - - Follows style guide - - DRY principle applied - - Clear variable/function names - - Appropriate comments - -3. **Testing** - - Unit tests comprehensive - - Integration tests pass - - Coverage adequate (>80%) - -4. **Security** - - No hardcoded secrets - - Input validation present - - SQL injection prevented - - XSS protection in place - -5. **Performance** - - Database queries optimized - - Images optimized - - Caching implemented where needed - -6. **Mobile Experience** - - Touch targets adequate (44x44px) - - Text readable without zooming - - Performance acceptable on mobile - - Interactions feel native - -## Commit Guidelines - -### Message Format -``` -(): - -[optional body] - -[optional footer] -``` - -### Types -- `feat`: New feature -- `fix`: Bug fix -- `docs`: Documentation only -- `style`: Formatting, missing semicolons, etc. -- `refactor`: Code change that neither fixes a bug nor adds a feature -- `test`: Adding missing tests -- `chore`: Maintenance tasks - -### Examples -```bash -git commit -m "feat(auth): Add remember me functionality" -git commit -m "fix(posts): Correct excerpt generation for short posts" -git commit -m "test(comments): Add tests for emoji reaction limits" -git commit -m "style(mobile): Improve button touch targets" -``` - -## Definition of Done - -A task is complete when: - -1. All code implemented to specification -2. Unit tests written and passing -3. Code coverage meets project requirements -4. Documentation complete (if applicable) -5. Code passes all configured linting and static analysis checks -6. Works beautifully on mobile (if applicable) -7. Implementation notes added to `plan.md` -8. Changes committed with proper message -9. Git note with task summary attached to the commit - -## Emergency Procedures - -### Critical Bug in Production -1. Create hotfix branch from main -2. Write failing test for bug -3. Implement minimal fix -4. Test thoroughly including mobile -5. Deploy immediately -6. Document in plan.md - -### Data Loss -1. Stop all write operations -2. Restore from latest backup -3. Verify data integrity -4. Document incident -5. Update backup procedures - -### Security Breach -1. Rotate all secrets immediately -2. Review access logs -3. Patch vulnerability -4. Notify affected users (if any) -5. Document and update security procedures - -## Deployment Workflow - -### Pre-Deployment Checklist -- [ ] All tests passing -- [ ] Coverage >80% -- [ ] No linting errors -- [ ] Mobile testing complete -- [ ] Environment variables configured -- [ ] Database migrations ready -- [ ] Backup created - -### Deployment Steps -1. Merge feature branch to main -2. Tag release with version -3. Push to deployment service -4. Run database migrations -5. Verify deployment -6. Test critical paths -7. Monitor for errors - -### Post-Deployment -1. Monitor analytics -2. Check error logs -3. Gather user feedback -4. Plan next iteration - -## Continuous Improvement - -- Review workflow weekly -- Update based on pain points -- Document lessons learned -- Optimize for user happiness -- Keep things simple and maintainable diff --git a/docs/decisions/README.md b/docs/decisions/README.md deleted file mode 100644 index e8916d8a3..000000000 --- a/docs/decisions/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Decision Records - -Architectural decision records and reviews. Each captures context, decision, and consequences. - -| Decision | File | Status | -|---|---|---| -| Architecture review (March 2026) | [`architecture-review-2026-03.md`](./architecture-review-2026-03.md) | Active | -| Navigation 3 parity strategy (Android + Desktop) | [`navigation3-parity-2026-03.md`](./navigation3-parity-2026-03.md) | Active | -| Navigation 3 API alignment audit | [`navigation3-api-alignment-2026-03.md`](./navigation3-api-alignment-2026-03.md) | Active | -| BLE KMP strategy (Kable) | [`ble-strategy.md`](./ble-strategy.md) | Decided | -| Hilt → Koin migration | [`koin-migration.md`](./koin-migration.md) | Complete | -| Testing consolidation (`core:testing`) | [`testing-consolidation-2026-03.md`](./testing-consolidation-2026-03.md) | Complete | - -For the current KMP migration status, see [`docs/kmp-status.md`](../kmp-status.md). -For the forward-looking roadmap, see [`docs/roadmap.md`](../roadmap.md). diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md deleted file mode 100644 index be43f823b..000000000 --- a/docs/decisions/architecture-review-2026-03.md +++ /dev/null @@ -1,256 +0,0 @@ -# Architecture Review — March 2026 - -> Status: **Active** -> Last updated: 2026-03-31 - -Re-evaluation of project modularity and architecture against modern KMP and Android best practices. Identifies gaps and actionable improvements across modularity, reusability, clean abstractions, DI, and testing. - -## Executive Summary - -The codebase is **~98% structurally KMP** — 18/20 core modules and 8/8 feature modules declare `jvm()` targets and cross-compile in CI. Shared `commonMain` code accounts for ~52K LOC vs ~18K platform-specific LOC (a 74/26 split). This is strong. - -Of the five structural gaps originally identified, four are resolved and one remains in progress: - -1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 8 files: `MainActivity`, `MeshUtilApplication`, Nav shell, DI config, and shared map UI components)* -2. ~~**Radio transport layer is app-locked**~~ — ✅ Resolved: `RadioTransport` interface in `core:repository/commonMain`; shared `StreamFrameCodec` + `TcpTransport` in `core:network`. -3. ~~**`java.*` APIs leak into `commonMain`**~~ — ✅ Resolved: `Locale`, `ConcurrentHashMap`, `ReentrantLock` purged. -4. ~~**Zero feature-level `commonTest`**~~ — ✅ Resolved: 193 shared tests across all 8 features; `core:testing` module established. -5. ~~**No `feature:connections` module**~~ — ✅ Resolved: KMP module with shared UI and dynamic transport detection. - -## Source Code Distribution - -| Source set | Files | ~LOC | Purpose | -|---|---:|---:|---| -| `core/*/commonMain` | 337 | 32,700 | Shared business/data logic | -| `feature/*/commonMain` | 146 | 19,700 | Shared feature UI + ViewModels | -| `feature/*/androidMain` | 62 | 14,700 | Platform UI (charts, previews, permissions) | -| `app/src/main` | 8 | ~450 | Android app shell + shared map UI components | -| `desktop/src` | 26 | 4,800 | Desktop app shell | -| `core/*/androidMain` | 49 | 3,500 | Platform implementations | -| `core/*/jvmMain` | 11 | ~500 | JVM actuals | -| `core/*/jvmAndroidMain` | 4 | ~200 | Shared JVM+Android code | - -**Key ratio:** 74% of production code is in `commonMain` (shared). Goal: 85%+. - ---- - -## A. Critical Modularity Gaps - -### A1. `app` module is a God module - -The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host, and shared flavor-agnostic UI. Originally it held **90 files / ~11K LOC**, now reduced to an **8-file shell** (6 original + 2 shared map UI components: `MapButton`, `MapControlsOverlay`): - -| Area | Files | LOC | Where it should live | -|---|---:|---:|---| -| `repository/radio/` | 22 | ~2,000 | `core:service` / `core:network` | -| `service/` | 12 | ~1,500 | Extracted to `core:service/androidMain` ✓ | -| `navigation/` | ~1 | ~200 | Root Nav 3 host wiring stays in `app`. Feature graphs moved to `feature:*`. | -| `settings/` ViewModels | 3 | ~350 | Thin Android wrappers (genuine platform deps) | -| `widget/` | 4 | ~300 | Extracted to `feature:widget` ✓ | -| `worker/` | 4 | ~350 | Extracted to `core:service/androidMain` and `feature:messaging/androidMain` ✓ | -| DI + Application + MainActivity | 5 | ~500 | Stay in `app` ✓ | -| UI screens + ViewModels | 5 | ~1,200 | Stay in `app` (Android-specific deps) | - -**Progress:** Extracted `ChannelViewModel` → `feature:settings/commonMain`, `NodeMapViewModel` → `feature:map/commonMain`, `NodeContextMenu` → `feature:node/commonMain`, `EmptyDetailPlaceholder` → `core:ui/commonMain`. Remaining extractions require radio/service layer refactoring (bigger scope). - -### A2. Radio interface layer is app-locked and non-KMP - -The core transport abstraction was previously locked in `app/repository/radio/` via `IRadioInterface`. This has been successfully refactored: - -1. Defined `RadioTransport` interface in `core:repository/commonMain` (replacing `IRadioInterface`) -2. Moved `StreamFrameCodec`-based framing to `core:network/commonMain` -3. Moved TCP transport to `core:network/jvmAndroidMain` -4. BLE, Serial, and Mock transports now reside in `core:network` and implement `RadioTransport`. - -**Recommended next steps:** -1. Move BLE transport to `core:ble/androidMain` -2. Move Serial/USB transport to `core:service/androidMain` - -### A3. No `feature:connections` module *(resolved 2026-03-12)* - -Device discovery UI was duplicated: -- Android: `app/ui/connections/` (13 files: `ConnectionsScreen`, `ScannerViewModel`, 10 components) -- Desktop: `desktop/ui/connections/DesktopConnectionsScreen.kt` (separate implementation) - -**Outcome:** Created `feature:connections` KMP module with: -- `commonMain`: `ScannerViewModel`, `ConnectionsScreen`, 11 shared UI components, `DeviceListEntry` sealed class, `GetDiscoveredDevicesUseCase` interface, `CommonGetDiscoveredDevicesUseCase` (TCP/recent devices) -- `androidMain`: `AndroidScannerViewModel` (BLE bonding, USB permissions), `AndroidGetDiscoveredDevicesUseCase` (BLE/NSD/USB discovery), `NetworkRepository`, `UsbRepository`, `SerialConnection` -- Desktop uses the shared `ConnectionsScreen` + `CommonGetDiscoveredDevicesUseCase` directly -- Dynamic transport detection via `RadioInterfaceService.supportedDeviceTypes` -- Module registered in both `AppKoinModule` and `DesktopKoinModule` - -### A4. `core:api` AIDL coupling - -`core:api` is Android-only (AIDL IPC). `ServiceClient` in `core:service/androidMain` wraps it. Desktop doesn't use it — it has `DirectRadioControllerImpl` in `core:service/commonMain`. - -**Recommendation:** The `DirectRadioControllerImpl` pattern is correct. Ensure `RadioController` (already in `core:model/commonMain`) is the canonical interface; deprecate the AIDL-based path for in-process usage. - ---- - -## B. KMP Platform Purity - -### B1. `java.util.Locale` leaks in `commonMain` *(resolved 2026-03-11)* - -| File | Usage | -|---|---| -| `core:data/.../TracerouteHandlerImpl.kt` | Replaced with `NumberFormatter.format(seconds, 1)` | -| `core:data/.../NeighborInfoHandlerImpl.kt` | Replaced with `NumberFormatter.format(seconds, 1)` | -| `core:prefs/.../MeshPrefsImpl.kt` | Replaced with locale-free `uppercase()` | - -**Outcome:** The three `Locale` usages identified in March were removed from `commonMain`. Follow-up cleanup in the same sprint also moved `ReentrantLock`-based `SyncContinuation` to `jvmAndroidMain`, replaced prefs `ConcurrentHashMap` caches with atomic persistent maps, and pushed enum reflection behind `expect`/`actual` so no known `java.*` runtime calls remain in `commonMain`. - -### B2. `ConcurrentHashMap` leaks in `commonMain` *(resolved 2026-03-11)* - -Formerly found in 3 prefs files: -- `core:prefs/.../MeshPrefsImpl.kt` -- `core:prefs/.../UiPrefsImpl.kt` -- `core:prefs/.../MapConsentPrefsImpl.kt` - -**Outcome:** These caches now use `AtomicRef>` helpers in `commonMain`, eliminating the last `ConcurrentHashMap` usage from shared prefs code. - -### B3. MQTT (Resolved) - -`MQTTRepositoryImpl` has been migrated to `commonMain` using KMQTT, replacing Eclipse Paho. - -**Fix:** Completed. -- `kmqtt` library integrated for full KMP support. - -### B4. Vico charts *(resolved)* - -Vico chart screens (DeviceMetrics, EnvironmentMetrics, SignalMetrics, PowerMetrics, PaxMetrics) have been migrated to `feature:node/commonMain` using Vico's KMP artifacts (`vico-compose`, `vico-compose-m3`). Desktop wires them via shared composables. No Android-only chart code remains. - -### B5. Cross-platform code deduplication *(resolved 2026-03-21)* - -Comprehensive audit of `androidMain` vs `jvmMain` duplication across all feature modules. Extracted shared components: - -| Component | Module | Eliminated from | -|---|---|---| -| `AlertHost` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` | -| `SharedDialogs` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` | -| `PlaceholderScreen` composable | `core:ui/commonMain` | 4 copies: `desktop/navigation`, `feature:map/jvmMain`, `feature:node/jvmMain` (×2) | -| `ThemePickerDialog` + `ThemeOption` | `feature:settings/commonMain` | Android `SettingsScreen.kt`, Desktop `DesktopSettingsScreen.kt` | -| `formatLogsTo()` + `redactedKeys` | `feature:settings/commonMain` (`LogFormatter.kt`) | Android + Desktop `LogExporter.kt` actuals | -| `handleNodeAction()` | `feature:node/commonMain` | Android `NodeDetailScreen.kt`, Desktop `NodeDetailScreens.kt` | -| `findNodeByNameSuffix()` | `feature:connections/commonMain` | Android USB matcher, TCP recent device matcher | - -Also fixed `Dispatchers.IO` usage in `StoreForwardPacketHandlerImpl` (would break iOS), removed dead `UIViewModel.currentAlert` property, and added `firebase-debug.log` to `.gitignore`. - ---- - -## C. DI Improvements - -### C1. ~~Desktop manual ViewModel wiring~~ *(resolved 2026-03-13)* - -`DesktopKoinModule.kt` originally had ~120 lines of hand-written `viewModel { ... }` blocks. These have been successfully replaced by including Koin modules from `commonMain` generated via the Koin K2 Compiler Plugin for automatic wiring. - -### C2. ~~Desktop stubs lack compile-time validation~~ *(resolved 2026-03-13)* - -`desktopPlatformStubsModule()` previously had stubs that were only validated at runtime. - -**Outcome:** Added `DesktopKoinTest.kt` using Koin's `verify()` API. This test validates the entire Desktop DI graph (including platform stubs and DataStores) during the build. Discovered and fixed missing stubs for `CompassHeadingProvider`, `PhoneLocationProvider`, and `MagneticFieldProvider`. - -### C3. DI module naming convention - -Android uses `@Module`-annotated classes (`CoreDataModule`, `CoreBleAndroidModule`). Desktop imports them as `CoreDataModule().coreDataModule()`. This works but the double-invocation pattern is non-obvious. - -**Recommendation:** Document the pattern in AGENTS.md. Consider if Koin Annotations 2.x supports a simpler import syntax. - ---- - -## D. Test Architecture - -### D1. Zero `commonTest` in feature modules *(resolved 2026-03-12)* - -| Module | `commonTest` | `test`/`androidUnitTest` | -|---|---:|---:| -| `feature:settings` | 33 | 20 | -| `feature:node` | 24 | 9 | -| `feature:messaging` | 21 | 5 | -| `feature:connections` | 27 | 0 | -| `feature:firmware` | 15 | 25 | -| `feature:wifi-provision` | 62 | 0 | - -**Outcome:** All 8 feature modules now have `commonTest` coverage (211 shared tests). Combined with 70 platform unit tests, feature modules have 281 tests total. All Compose UI tests have been migrated from `androidTest` to `commonTest` using CMP `runComposeUiTest`; instrumented test infrastructure has been removed from CI. - -### D2. No shared test fixtures *(resolved 2026-03-12)* - -`core:testing` module established with shared fakes (`FakeNodeRepository`, `FakeServiceRepository`, `FakeRadioController`, `FakePacketRepository`) and `TestDataFactory` builders. Used by all feature `commonTest` suites. - -### D3. Core module test gaps - -36 `commonTest` files exist but are concentrated in `core:domain` (22 files) and `core:data` (10 files). Limited or zero tests in: -- `core:service` (has `ServiceRepositoryImpl`, `DirectRadioControllerImpl`, `MeshServiceOrchestrator`) -- `core:network` (has `StreamFrameCodecTest` — 10 tests; `TcpTransport` untested) -- `core:ble` (connection state machine) -- `core:ui` (utility functions) - -`core:prefs` now has 12 `commonTest` tests (3 files: `FilterPrefsTest`, `TakPrefsTest`, `NotificationPrefsTest`) migrated from `androidHostTest` using Okio + `PreferenceDataStoreFactory.createWithPath()` for KMP compatibility. - -### D4. Desktop has 2 tests - -`desktop/src/test/` contains `DesktopKoinTest.kt` and `DesktopTopLevelDestinationParityTest.kt`. Still needs: -- Navigation graph coverage - ---- - -## E. Module Extraction Priority - -Ordered by impact × effort: - -| Priority | Extraction | Impact | Effort | Enables | -|---:|---|---|---|---| -| 1 | ~~`java.*` purge from `commonMain` (B1, B2)~~ | High | Low | ~~iOS target declaration~~ ✅ Done | -| 2 | Radio transport interfaces to `core:repository` (A2) | High | Medium | Transport unification | -| 3 | `core:testing` shared fixtures (D2) | Medium | Low | Feature commonTest | -| 4 | Feature `commonTest` (D1) | Medium | Medium | KMP test coverage | -| 5 | `feature:connections` (A3) | High | Medium | ~~Desktop connections~~ ✅ Done | -| 6 | Service/worker extraction from `app` (A1) | Medium | Medium | Thin app module | -| 7 | ~~Desktop Koin auto-wiring (C1, C2)~~ | Medium | Low | ✅ Resolved 2026-03-13 | -| 8 | MQTT KMP (B3) | Medium | High | Desktop/iOS MQTT | -| 9 | KMP charts (B4) | Medium | High | Desktop metrics | -| 10 | ~~iOS target declaration~~ | High | Low | ~~CI purity gate~~ ✅ Done | - ---- - -## Scorecard Update - -| Area | Previous | Current | Notes | -|---|---:|---:|---| -| Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared | -| Shared feature/UI logic | 9.5/10 | **9/10** | All 8 KMP features; connections unified; cross-platform deduplication complete | -| Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files | -| Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | -| CI confidence | 8.5/10 | **9/10** | 26 modules validated; feature:connections + feature:wifi-provision + desktop in CI; native release installers | -| DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | — | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | - ---- - -## F. JVM/Desktop Database Lifecycle - -Room KMP's `setAutoCloseTimeout` API is Android-only. On JVM/Desktop, once a Room database is built, its SQLite connections (5 per WAL-mode DB: 4 readers + 1 writer) remain open indefinitely until explicitly closed via `RoomDatabase.close()`. - -### Problem - -When a user switches between multiple mesh devices, the previous device's database remained open in the in-memory cache. Each idle database consumed ~32 MB (connection pool + prepared statement caches), leading to unbounded memory growth proportional to the number of devices ever connected in a session. - -### Solution - -`DatabaseManager.switchActiveDatabase()` now explicitly closes the previously active database via `closeCachedDatabase()` before activating the new one. The closed database is removed from the in-memory cache but its file is preserved, allowing transparent re-opening on next access. - -Additional fixes applied: -1. **Init-order bug**: `dbCache` was declared after `currentDb`, causing NPE during `stateIn`'s `initialValue` evaluation. Reordered to ensure `dbCache` is initialized first. -2. **Corruption handlers**: `ReplaceFileCorruptionHandler` added to `createDatabaseDataStore()` on both JVM and Android, preventing DataStore corruption from crashing the app. -3. **`desktopDataDir()` deduplication**: Made public in `core:database/jvmMain` and removed the duplicate from `DesktopPlatformModule`, establishing a single source of truth for the desktop data directory. -4. **DataStore scope consolidation**: Replaced two separate `CoroutineScope` instances with a single shared `dataStoreScope` in `DesktopPlatformModule`. -5. **Coil cache path**: Desktop `Main.kt` updated to use `desktopDataDir()` instead of hardcoded `user.home`. - ---- - -## References - -- Current migration status: [`kmp-status.md`](./kmp-status.md) -- Roadmap: [`roadmap.md`](./roadmap.md) -- Agent guide: [`../AGENTS.md`](../AGENTS.md) -- Decision records: [`decisions/`](./decisions/) - diff --git a/docs/decisions/navigation3-api-alignment-2026-03.md b/docs/decisions/navigation3-api-alignment-2026-03.md deleted file mode 100644 index 6a0925152..000000000 --- a/docs/decisions/navigation3-api-alignment-2026-03.md +++ /dev/null @@ -1,124 +0,0 @@ - - -# Navigation 3 & Material 3 Adaptive — API Alignment Audit - -**Date:** 2026-03-26 -**Status:** Active -**Scope:** Adoption of Navigation 3 `1.1.0-beta01` Scene APIs, transition metadata, ViewModel scoping, and Material 3 Adaptive integration. -**Supersedes:** [`navigation3-parity-2026-03.md`](navigation3-parity-2026-03.md) Alpha04 Changelog section (versions updated). - -## Current Dependency Baseline - -| Library | Version | Group | -|---|---|---| -| Navigation 3 UI | `1.1.0-beta01` | `org.jetbrains.androidx.navigation3:navigation3-ui` | -| Navigation Event | `1.1.0-alpha01` | `org.jetbrains.androidx.navigationevent:navigationevent-compose` | -| Lifecycle ViewModel Navigation3 | `2.11.0-alpha02` | `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3` | -| Material 3 Adaptive | `1.3.0-alpha06` | `org.jetbrains.compose.material3.adaptive:adaptive*` | -| Material 3 Adaptive Navigation Suite | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3-adaptive-navigation-suite` | -| Compose Multiplatform | `1.11.0-beta01` | `org.jetbrains.compose` | -| Compose Multiplatform Material 3 | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3` | - -## API Audit: What's Available vs. What We Use - -### 1. NavDisplay — Scene Architecture (available since `1.1.0-alpha04`, stable in `beta01`) - -**Available APIs we're NOT using:** - -| API | Purpose | Status in project | -|---|---|---| -| `sceneStrategies: List>` | Allows NavDisplay to render multi-pane Scenes | ✅ Used — `DialogSceneStrategy`, `ListDetailSceneStrategy`, `SupportingPaneSceneStrategy`, `SinglePaneSceneStrategy` | -| `SceneStrategy` interface | Custom scene calculation from backstack entries | ✅ Used via built-in strategies | -| `DialogSceneStrategy` | Renders `entry(metadata = dialog())` entries as overlay Dialogs | ✅ Adopted | -| `SceneDecoratorStrategy` | Wraps/decorates scenes with additional UI | ❌ Not used | -| `NavEntry.metadata` | Attaches typed metadata to entries (transitions, dialog hints, Scene classification) | ✅ Used — `ListDetailSceneStrategy.listPane()`, `.detailPane()`, `.extraPane()` | -| `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-entry custom transitions via metadata | ❌ Not used | -| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ✅ Used — 350 ms crossfade | -| `sharedTransitionScope: SharedTransitionScope?` | Shared element transitions between scenes | ❌ Not used | -| `entryDecorators: List>` | Wraps entry content with additional behavior | ✅ Used — `SaveableStateHolderNavEntryDecorator` + `ViewModelStoreNavEntryDecorator` | - -**APIs we ARE using correctly:** - -| API | Usage | -|---|---| -| `NavDisplay(backStack, entryProvider, modifier)` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` | -| `rememberNavBackStack(SavedStateConfiguration, startKey)` | Backstack persistence | -| `entryProvider { entry { ... } }` | All feature graph registrations | -| `NavigationBackHandler` from `navigationevent-compose` | Used with `ListDetailSceneStrategy` | - -### 2. ViewModel Scoping (`lifecycle-viewmodel-navigation3` `2.11.0-alpha02`) - -**Key finding:** The `ViewModelStoreNavEntryDecorator` is available and provides automatic per-entry ViewModel scoping tied to backstack lifetime. The project passes it as an `entryDecorator` to `NavDisplay` via `MeshtasticNavDisplay` in `core:ui/commonMain`. - -ViewModels obtained via `koinViewModel()` inside `entry` blocks are scoped to the entry's backstack lifetime and automatically cleared when the entry is popped. - -### 3. Material 3 Adaptive — Nav3 Scene Integration - -**Key finding:** The JetBrains `adaptive-navigation3` artifact at `1.3.0-alpha06` includes `ListDetailSceneStrategy` and `SupportingPaneSceneStrategy`. The project uses both via `rememberListDetailSceneStrategy` and `rememberSupportingPaneSceneStrategy` in `MeshtasticNavDisplay`, with draggable pane dividers via `VerticalDragHandle` + `paneExpansionDraggable`. - -This means the project **successfully** uses the M3 Adaptive Scene bridge through `NavDisplay(sceneStrategies = ...)`. Feature entries annotate themselves with `ListDetailSceneStrategy.listPane()`, `.detailPane()`, or `.extraPane()` metadata. - -**When to revisit:** Monitor the JetBrains adaptive fork for `MaterialListDetailSceneStrategy` inclusion. It will likely arrive when the JetBrains fork catches up to the AndroidX `1.3.0-alpha09+` feature set. - -### 4. NavigationSuiteScaffold (`1.11.0-alpha05`) - -**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavigationSuite` now uses `NavigationSuiteScaffold` with `calculateFromAdaptiveInfo()` and custom `NavigationSuiteType` coercion. No further alignment needed. - -## Prioritized Opportunities - -### P0: Add `ViewModelStoreNavEntryDecorator` to NavDisplay (high-value, low-risk) - -**Status:** ✅ Adopted (2026-03-26). Each backstack entry now gets its own `ViewModelStoreOwner` via `rememberViewModelStoreNavEntryDecorator()`. ViewModels obtained via `koinViewModel()` are automatically cleared when their entry is popped. Encapsulated in `MeshtasticNavDisplay` in `core:ui/commonMain`. - -**Impact:** Fixes subtle ViewModel leaks where popped entries retain their ViewModel in the Activity/Window store. Eliminates the need for manual `key = "metrics-$destNum"` ViewModel keying patterns over time. - -### P1: Add default NavDisplay transitions (medium-value, low-risk) - -**Status:** ✅ Adopted (2026-03-26). A shared 350 ms crossfade (`fadeIn` + `fadeOut`) is applied for both forward and pop navigation via `MeshtasticNavDisplay`. This replaces the library's platform defaults (Android: 700 ms fade; Desktop: no animation) with a faster, consistent transition. - -**Impact:** Immediate UX improvement on both Android and Desktop. Desktop now has visible navigation transitions. - -### P2: Adopt `DialogSceneStrategy` for navigation-driven dialogs (medium-value, medium-risk) - -**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavDisplay` includes `DialogSceneStrategy` in `sceneStrategies` before `SinglePaneSceneStrategy`. Feature modules can now use `entry(metadata = DialogSceneStrategy.dialog()) { ... }` to render entries as overlay Dialogs with proper backstack lifecycle and predictive-back support. - -**Impact:** Cleaner dialog lifecycle management available for future dialog routes. Existing dialogs via `AlertHost` are unaffected. - -### Consolidation: `MeshtasticNavDisplay` shared wrapper - -**Status:** ✅ Adopted (2026-03-26). A new `MeshtasticNavDisplay` composable in `core:ui/commonMain` encapsulates the standard `NavDisplay` configuration: -- Entry decorators: `rememberSaveableStateHolderNavEntryDecorator` + `rememberViewModelStoreNavEntryDecorator` -- Scene strategies: `DialogSceneStrategy` + `SinglePaneSceneStrategy` -- Transition specs: 350 ms crossfade (forward + pop) - -Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` now call `MeshtasticNavDisplay` instead of configuring `NavDisplay` directly. The `lifecycle-viewmodel-navigation3` dependency was moved from host modules to `core:ui`. - -### P3: Per-entry transition metadata (low-value until Scene adoption) - -Individual entries can declare custom transitions via `entry(metadata = NavDisplay.transitionSpec { ... })`. This is most useful when different route types should animate differently (e.g., detail screens slide in, settings screens fade). - -**Impact:** Polish improvement. Low priority until default transitions (P1) are established. Now unblocked by P1 adoption. - -### Deferred: Custom Scene strategies - -The `ListDetailSceneStrategy` and `SupportingPaneSceneStrategy` are adopted and working. Consider writing additional custom `SceneStrategy` implementations for specialized layouts (e.g., three-pane "Power User" scenes) as the Navigation 3 Scene API matures. - -## Decision - -~~Adopt **P0** (ViewModel scoping) and **P1** (default transitions) now. Defer P2/P3 and Scene-based multi-pane until the JetBrains adaptive fork adds `MaterialListDetailSceneStrategy`.~~ - -**Updated 2026-03-26:** P0, P1, and P2 adopted and consolidated into `MeshtasticNavDisplay` in `core:ui/commonMain`. P3 (per-entry transitions) is available for incremental adoption by feature modules. Scene-based multi-pane remains deferred. - -## References - -- Navigation 3 source: `navigation3-ui` `1.1.0-beta01` (inspected from Gradle cache) -- [`NavDisplay.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt) (upstream) -- [`SceneStrategy.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/scene/SceneStrategy.kt) (upstream) -- Material 3 Adaptive JetBrains fork: `org.jetbrains.compose.material3.adaptive` `1.3.0-alpha06` diff --git a/docs/decisions/navigation3-parity-2026-03.md b/docs/decisions/navigation3-parity-2026-03.md deleted file mode 100644 index 1d1a8c7ed..000000000 --- a/docs/decisions/navigation3-parity-2026-03.md +++ /dev/null @@ -1,167 +0,0 @@ - - -# Navigation 3 Parity Strategy (Android + Desktop) - -**Date:** 2026-03-11 -**Status:** Implemented (2026-03-21) -**Scope:** `app` and `desktop` navigation structure using shared `core:navigation` routes - -## Context - -Desktop and Android both use Navigation 3 typed routes from `core:navigation`. Previously graph wiring had diverged — desktop used a separate `DesktopDestination` enum with 6 entries (including a top-level Firmware tab) while Android used 5 entries. - -This has been resolved. Both shells now use the shared `TopLevelDestination` enum from `core:navigation/commonMain` with 5 entries (Conversations, Nodes, Map, Settings, Connections). Firmware is an in-flow route on both platforms. - -Both modules still define separate graph-builder files (`app/navigation/*.kt`, `desktop/navigation/*.kt`) with different destination coverage and placeholder behavior, but the **top-level shell structure is unified**. - -## Current-State Findings - -1. **Top-level destinations are unified.** - - Both shells iterate `TopLevelDestination.entries` from `core:navigation/commonMain`. - - Shared icon mapping lives in `core:ui` (`TopLevelDestinationExt.icon`). - - Parity tests exist in both `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). -2. **Feature coverage is unified via `commonMain` feature graphs.** - - The `settingsGraph`, `nodesGraph`, `contactsGraph`, `connectionsGraph`, `firmwareGraph`, and `mapGraph` are now fully shared and exported from their respective feature modules' `commonMain` source sets. - - Desktop acts as a thin shell, delegating directly to these shared graphs. -3. **Saved-state route registration is fully shared.** - - `MeshtasticNavSavedStateConfig` in `core:navigation/commonMain` maintains the unified `SavedStateConfiguration` serializer list. - - Both Android and Desktop reference this shared config when instantiating `rememberNavBackStack`. -4. **Predictive back handling is KMP native.** - - Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`. - -## Alpha04 → Beta01 Changelog Impact Check - -Source reviewed: Navigation 3 `1.1.0-beta01` (JetBrains fork), CMP `1.11.0-beta01`, Lifecycle `2.11.0-alpha02`. - -> **Superseded by:** [`navigation3-api-alignment-2026-03.md`](navigation3-api-alignment-2026-03.md) for the full API surface audit and Scene architecture adoption plan. - -1. **NavDisplay API updated to Scene-based architecture.** - - The `sceneStrategy: SceneStrategy` parameter is deprecated in favor of `sceneStrategies: List>`. - - New `sceneDecoratorStrategies: List>` parameter available. - - New `sharedTransitionScope: SharedTransitionScope?` parameter for shared element transitions. - - Existing shell patterns in `app` and `desktop` remain valid using the default `SinglePaneSceneStrategy`. -2. **Entry-scoped ViewModel lifecycle adopted.** - - Both `app` and `desktop` now use `MeshtasticNavDisplay` (`core:ui/commonMain`), which applies `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` per active backstack. - - ViewModels obtained via `koinViewModel()` inside `entry` blocks are now scoped to the entry's backstack lifetime. -3. **No direct Navigation 3 API breakage.** - - Release is beta (API stabilized). No migration from alpha04 was required for existing usage patterns. -4. **Primary risk is dependency wiring drift, not runtime behavior.** - - JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog. - - Note: The `remember*` composable factory functions from `navigation3-runtime` are not visible in non-KMP Android modules due to Kotlin metadata resolution. Use direct class constructors instead (as done in `app/Main.kt`). -5. **Saved-state and typed-route parity improved.** - - Both hosts share `MeshtasticNavSavedStateConfig` from `core:navigation/commonMain` via `MultiBackstack`, reducing platform drift risk in serializer registration. -6. **Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`).** - -### Actions Taken - -- Renamed all JetBrains-forked lifecycle/nav3 version catalog aliases from `androidx-*` to `jetbrains-*` prefix to make fork provenance unambiguous: - - `jetbrains-lifecycle-runtime`, `jetbrains-lifecycle-runtime-compose`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-viewmodel-navigation3` - - `jetbrains-navigation3-runtime`, `jetbrains-navigation3-ui` -- Documented in the version catalog that `jetbrains-navigation3-runtime` intentionally maps to `navigation3-ui` until a separate runtime artifact is published. -- Migrated `core:data` `commonMain` from `androidx.lifecycle:lifecycle-runtime` (Google) to `org.jetbrains.androidx.lifecycle:lifecycle-runtime` (JetBrains fork) for full consistency. -- Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`). -- Consolidated `app` adaptive dependencies to JetBrains Material 3 Adaptive coordinates (`org.jetbrains.compose.material3.adaptive:*`) so Android and Desktop consume the same adaptive artifact family. The Android-only navigation suite remains on `androidx.compose.material3:material3-adaptive-navigation-suite`. - -### Deferred Follow-ups - -- Add automated validation that desktop serializer registrations stay in sync with shared route keys. - -## Options Evaluated - -### Option A: Reuse `:app` navigation implementation directly in desktop - -**Pros** -- Maximum short-term parity in structure. - -**Cons** -- `:app` graph code is tightly coupled to Android wrappers (`Android*ViewModel`, Android-only screen wrappers, app-specific UI state like scroll-to-top flows). -- Pulling this code into desktop would either fail at compile-time or force additional platform branching in app files. -- Violates clean module boundaries (`desktop` should not depend on Android-specific app glue). - -**Decision:** Not recommended. - -### Option B: Keep fully separate desktop graph and replicate app behavior manually - -**Pros** -- Lowest refactor cost right now. -- Keeps platform customization simple. - -**Cons** -- Drift is guaranteed over time. -- No central policy for intentional vs accidental divergence. -- High maintenance burden for parity-sensitive flows. - -**Decision:** Not recommended as a long-term strategy. - -### Option C (Recommended): Hybrid shared contract + platform graph adapters - -**Pros** -- Preserves platform-specific wiring where needed. -- Reduces drift by moving parity-sensitive definitions to shared contracts. -- Enables explicit, testable exceptions for desktop-only or Android-only behavior. - -**Cons** -- Requires incremental extraction work. -- Needs light governance (parity matrix + tests + docs). - -**Decision:** Recommended. - -## Decision - -Adopt a **hybrid parity model**: - -1. Keep platform graph registration in `app` and `desktop`. -2. Extract parity-sensitive navigation metadata into shared contracts (top-level destination set/order, route ownership map, and allowed platform exceptions). -3. Keep platform-specific destination implementations as adapters around shared route keys. -4. Add route parity tests so drift is detected automatically. - -## Implementation Plan - -### Phase 1 (Immediate): Stop drift on shell structure ✅ - -- ✅ Aligned desktop top-level destination policy with Android (removed Firmware from top-level; kept as in-flow). -- ✅ Both shells now use shared `TopLevelDestination` enum from `core:navigation/commonMain`. -- ✅ Shared icon mapping in `core:ui` (`TopLevelDestinationExt.icon`). -- Parity matrix documented inline: top-level set is Conversations, Nodes, Map, Settings, Connections on both platforms. - -### Phase 2 (Near-term): Extract shared navigation contracts ✅ (partially) - -- ✅ Shared `TopLevelDestination` enum with `fromNavKey()` already serves as the canonical metadata object. -- Both `app` and `desktop` shells iterate `TopLevelDestination.entries` — no separate `DesktopDestination` enum remains. -- Remaining: optional visibility flags by platform, route grouping metadata (lower priority since shells are unified). - -### Phase 3 (Near-term): Add parity checks ✅ (partially) - -- ✅ `NavigationParityTest` in `core:navigation/commonTest` — asserts 5 top-level destinations and `fromNavKey` matching. -- ✅ `DesktopTopLevelDestinationParityTest` in `desktop/test` — asserts desktop routes match Android parity set and firmware is not top-level. -- Remaining: assert every desktop serializer registration corresponds to an actual route; assert every intentional exception is listed. - -### Phase 4 (Mid-term): Reduce app-specific graph coupling - -- Move reusable graph composition helpers out of `:app` where practical (while keeping Android-only wrappers in Android source sets). -- Keep desktop-specific placeholder implementations, but tie them to explicit parity exception entries. - -## Consequences - -- Navigation behavior remains platform-adaptive, but parity expectations become explicit and enforceable. -- Desktop can keep legitimate deviations (map/charts/platform integrations) without silently changing IA. -- New route additions will require touching one shared contract plus platform implementations, making review scope clearer. - -## Source Anchors - -- Shared routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` -- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt` -- Android shell: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt` -- Shared graph registrations: `feature/*/src/commonMain/kotlin/org/meshtastic/feature/*/navigation/` -- Platform graph content: `feature/*/src/{androidMain,jvmMain}/kotlin/org/meshtastic/feature/*/navigation/` -- Desktop shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` - - diff --git a/docs/kmp-status.md b/docs/kmp-status.md index 1f8ce1062..bea19e8c3 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -1,6 +1,6 @@ # KMP Migration Status -> Last updated: 2026-04-10 +> Last updated: 2026-04-13 Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/). @@ -49,7 +49,7 @@ Modules that share JVM-specific code between Android and desktop now standardize | `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; fully shared `contactsGraph`, `MessageScreen`, `ContactsScreen`, and `MessageListPaged` | | `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection | | `feature:intro` | — | — | Screens remain in `androidMain`; shared ViewModel only | -| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`, and `TracerouteNodeSelection`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` | +| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` | | `feature:firmware` | ✅ | ✅ Fully KMP: Unified OTA, native Secure DFU, USB/UF2, FirmwareRetriever | | `feature:wifi-provision` | ✅ | ✅ KMP WiFi provisioning via BLE (Nymea protocol); shared UI and ViewModel | | `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. | @@ -79,9 +79,7 @@ Working Compose Desktop application with: | Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | **9/10** | 26 modules validated (including feature:wifi-provision); native release installers automated | | DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | - -> See [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md) for the full gap analysis. +| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils, desktop navigation graphs | ## Completion Estimates @@ -105,11 +103,11 @@ Based on the latest codebase investigation, the following steps are proposed to | Decision | Status | Details | |---|---|---| -| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | See [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md) | +| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | Both shells use shared `TopLevelDestination` enum and `MeshtasticNavDisplay` from `core:ui/commonMain`; parity tests in `core:navigation/commonTest` | | Hilt → Koin | ✅ Done | See [`decisions/koin-migration.md`](./decisions/koin-migration.md) | | BLE abstraction (Kable) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) | | Firmware KMP migration (pure Secure DFU) | ✅ Done | Native Nordic Secure DFU protocol reimplemented in pure KMP using Kable; desktop is first-class target | -| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta01`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | +| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta02`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | | JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime | | Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained | | Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` | @@ -127,7 +125,7 @@ Based on the latest codebase investigation, the following steps are proposed to - Firmware remains available as an in-flow route instead of a top-level destination, matching Android information architecture. - Android navigation graphs are decoupled and extracted into their respective feature modules, aligning with the Desktop architecture. - Parity tests exist in `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). -- Remaining parity work is documented in [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md): serializer registration validation and platform exception tracking. +- Remaining parity work: serializer registration validation and platform exception tracking. ## App Module Thinning Status @@ -159,19 +157,17 @@ Remaining to be extracted from `:app` or unified in `commonMain`: | Dependency | Version | Why | |---|---|---| -| Compose Multiplatform | `1.11.0-beta01` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha05` | -| Compose Multiplatform Material 3 | `1.11.0-alpha05` | Material 3 components including `NavigationSuiteScaffold` | -| Koin | `4.2.0` | Nav3 + K2 compiler plugin support | -| JetBrains Lifecycle | `2.11.0-alpha02` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels | -| JetBrains Navigation 3 | `1.1.0-beta01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs | +| Compose Multiplatform | `1.11.0-beta02` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha06` | +| Compose Multiplatform Material 3 | `1.11.0-alpha06` | Material 3 components including `NavigationSuiteScaffold` | +| Koin | `4.2.1` | Nav3 + K2 compiler plugin support | +| JetBrains Lifecycle | `2.11.0-alpha03` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels | +| JetBrains Navigation 3 | `1.1.0-rc01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs | | JetBrains Navigation Event | `1.1.0-alpha01` | KMP `NavigationBackHandler` for predictive back | | JetBrains Material 3 Adaptive | `1.3.0-alpha06` | `ListDetailPaneScaffold`, `ThreePaneScaffold`, Large/XL breakpoints | | Kable BLE | `0.42.0` | Provides fully multiplatform BLE support | **Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features. -> See [`decisions/navigation3-api-alignment-2026-03.md`](./decisions/navigation3-api-alignment-2026-03.md) for the full Navigation 3 API surface audit and Scene architecture adoption plan. - ## References - Roadmap: [`docs/roadmap.md`](./roadmap.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c9445485..d97995bb4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ > Last updated: 2026-04-10 -Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). For the full gap analysis, see [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md). +Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). ## Architecture Health (Immediate) diff --git a/docs/testing/baseline_coverage.md b/docs/testing/baseline_coverage.md deleted file mode 100644 index 6445ea9e5..000000000 --- a/docs/testing/baseline_coverage.md +++ /dev/null @@ -1,6 +0,0 @@ -# Baseline Test Coverage Report -**Date:** Wednesday, March 18, 2026 -**Overall Project Coverage:** 8.796% -**App Module Coverage:** 1.6404% - -This baseline was captured using `./gradlew koverLog` at the start of the 'Expand Testing Coverage' track. \ No newline at end of file diff --git a/docs/testing/final_coverage.md b/docs/testing/final_coverage.md deleted file mode 100644 index bc502d704..000000000 --- a/docs/testing/final_coverage.md +++ /dev/null @@ -1,18 +0,0 @@ -# Final Test Coverage Report -**Date:** Wednesday, March 18, 2026 -**Overall Project Coverage:** 10.2591% (Baseline: 8.796%) -**Absolute Increase:** +1.46% - -## Module Highlights -| Module | Coverage | Notes | -| :--- | :--- | :--- | -| `core:domain` | 26.55% | UseCase gap fill complete. | -| `feature:intro` | 30.76% | ViewModel tests enabled. | -| `feature:map` | 33.33% | BaseMapViewModel tests refactored. | -| `feature:node` | 24.70% | Metrics, Detail, Compass, and Filter tests added/refactored. | -| `feature:connections` | 26.49% | ScannerViewModel verified. | -| `feature:messaging` | 18.54% | MessageViewModel verified. | - -This report concludes the 'Expand Testing Coverage' track. -Significant improvements were made in ViewModel testability through interface extraction and Mokkery/Turbine migration. -Foundational logic in `core:network` was strengthened with Kotest property-based tests. \ No newline at end of file From b13f9bf9893e865adfa939a144854e8076af60ea Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:25:23 -0500 Subject: [PATCH 045/114] fix(resources): add resourcePrefix to KMP + widget modules, rename prefixed resources (#5111) --- app/src/main/AndroidManifest.xml | 2 +- core/datastore/build.gradle.kts | 12 +- .../RecentAddressesDataSourceTest.kt | 286 ++++++++++++++++++ core/resources/build.gradle.kts | 5 +- .../raw/{alert.mp3 => meshtastic_alert.mp3} | Bin .../service/MeshServiceNotificationsImpl.kt | 3 +- feature/widget/build.gradle.kts | 1 + .../feature/widget/LocalStatsWidget.kt | 6 +- .../{app_icon.xml => widget_app_icon.xml} | 0 .../{ic_refresh.xml => widget_ic_refresh.xml} | 0 ...t_info.xml => widget_local_stats_info.xml} | 0 11 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt rename core/resources/src/androidMain/res/raw/{alert.mp3 => meshtastic_alert.mp3} (100%) rename feature/widget/src/main/res/drawable/{app_icon.xml => widget_app_icon.xml} (100%) rename feature/widget/src/main/res/drawable/{ic_refresh.xml => widget_ic_refresh.xml} (100%) rename feature/widget/src/main/res/xml/{local_stats_widget_info.xml => widget_local_stats_info.xml} (100%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 43468c69d..f7d2ce900 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -288,7 +288,7 @@ + android:resource="@xml/widget_local_stats_info" /> diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 903dde119..7d46cc831 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -24,7 +24,11 @@ plugins { kotlin { jvm() - android { namespace = "org.meshtastic.core.datastore" } + android { + namespace = "org.meshtastic.core.datastore" + androidResources.enable = false + withHostTest {} + } sourceSets { commonMain.dependencies { @@ -36,5 +40,11 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kermit) } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.okio) + } } } diff --git a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt new file mode 100644 index 000000000..3acd29cb9 --- /dev/null +++ b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.datastore + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import okio.FileSystem +import okio.Path +import org.meshtastic.core.datastore.model.RecentAddress +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +class RecentAddressesDataSourceTest { + private lateinit var tmpDir: Path + private lateinit var dataSource: RecentAddressesDataSource + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @BeforeTest + fun setup() { + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "recentAddressesTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) + val dataStore = + PreferenceDataStoreFactory.createWithPath( + scope = testScope, + produceFile = { tmpDir / "test.preferences_pb" }, + ) + dataSource = RecentAddressesDataSource(dataStore) + } + + @AfterTest + fun tearDown() { + FileSystem.SYSTEM.deleteRecursively(tmpDir) + } + + // ---- recentAddresses flow ---- + + @Test + fun `recentAddresses emits empty list when no data stored`() = testScope.runTest { + val result = dataSource.recentAddresses.first() + assertTrue(result.isEmpty()) + } + + @Test + fun `setRecentAddresses persists and emits the list`() = testScope.runTest { + val addresses = + listOf( + RecentAddress(address = "192.168.1.1", name = "Home"), + RecentAddress(address = "10.0.0.1", name = "Office"), + ) + dataSource.setRecentAddresses(addresses) + + val result = dataSource.recentAddresses.first() + assertEquals(addresses, result) + } + + @Test + fun `setRecentAddresses overwrites previous value`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.2.3.4", "Old"))) + dataSource.setRecentAddresses(listOf(RecentAddress("5.6.7.8", "New"))) + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("5.6.7.8", result[0].address) + } + + // ---- add() LRU behaviour ---- + + @Test + fun `add to empty list stores single entry`() = testScope.runTest { + dataSource.add(RecentAddress("192.168.0.1", "Router")) + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("192.168.0.1", result[0].address) + } + + @Test + fun `add prepends new address to front`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "Existing"))) + dataSource.add(RecentAddress("2.2.2.2", "New")) + + val result = dataSource.recentAddresses.first() + assertEquals("2.2.2.2", result[0].address) + assertEquals("1.1.1.1", result[1].address) + } + + @Test + fun `add deduplicates by address moving existing entry to front with updated name`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "First"), RecentAddress("2.2.2.2", "Second"))) + dataSource.add(RecentAddress("2.2.2.2", "Second-updated")) + + val result = dataSource.recentAddresses.first() + assertEquals(2, result.size) + assertEquals("2.2.2.2", result[0].address) + assertEquals("Second-updated", result[0].name) + assertEquals("1.1.1.1", result[1].address) + } + + @Test + fun `add enforces CACHE_CAPACITY of 3 evicting oldest entry`() = testScope.runTest { + dataSource.setRecentAddresses( + listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")), + ) + dataSource.add(RecentAddress("4.4.4.4", "D")) + + val result = dataSource.recentAddresses.first() + assertEquals(3, result.size) + assertEquals("4.4.4.4", result[0].address) + assertEquals("1.1.1.1", result[1].address) + assertEquals("2.2.2.2", result[2].address) + assertFalse(result.any { it.address == "3.3.3.3" }) + } + + @Test + fun `add re-adding the same address at front keeps capacity`() = testScope.runTest { + dataSource.setRecentAddresses( + listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")), + ) + dataSource.add(RecentAddress("1.1.1.1", "A")) + + val result = dataSource.recentAddresses.first() + assertEquals(3, result.size) + assertEquals("1.1.1.1", result[0].address) + } + + // ---- remove() ---- + + @Test + fun `remove deletes the matching address`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"))) + dataSource.remove("1.1.1.1") + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + assertEquals("2.2.2.2", result[0].address) + } + + @Test + fun `remove on unknown address is a no-op`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"))) + dataSource.remove("9.9.9.9") + + val result = dataSource.recentAddresses.first() + assertEquals(1, result.size) + } + + @Test + fun `remove last address yields empty list`() = testScope.runTest { + dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"))) + dataSource.remove("1.1.1.1") + + assertTrue(dataSource.recentAddresses.first().isEmpty()) + } + + // ---- legacy JSON parsing (via LegacyParsingHarness) ---- + + @Test + fun `legacy JsonObject array is parsed correctly`() = testScope.runTest { + val legacyJson = + """[{"address":"192.168.1.100","name":"NodeA"},{"address":"192.168.1.101","name":"NodeB"}]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(2, result.size) + assertEquals("192.168.1.100", result[0].address) + assertEquals("NodeA", result[0].name) + assertEquals("192.168.1.101", result[1].address) + assertEquals("NodeB", result[1].name) + } + + @Test + fun `legacy bare string JsonPrimitive array is parsed correctly`() = testScope.runTest { + // Old clients stored plain IP strings with no name field + val legacyJson = """["192.168.1.50","10.0.0.2"]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(2, result.size) + assertEquals("192.168.1.50", result[0].address) + assertEquals("Meshtastic", result[0].name) + assertEquals("10.0.0.2", result[1].address) + assertEquals("Meshtastic", result[1].name) + } + + @Test + fun `legacy JsonObject missing address field is skipped`() = testScope.runTest { + val legacyJson = """[{"name":"NoAddress"},{"address":"1.2.3.4","name":"Good"}]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(1, result.size) + assertEquals("1.2.3.4", result[0].address) + } + + @Test + fun `legacy JsonObject missing name field is skipped`() = testScope.runTest { + val legacyJson = """[{"address":"1.2.3.4"},{"address":"5.6.7.8","name":"Good"}]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(1, result.size) + assertEquals("5.6.7.8", result[0].address) + } + + @Test + fun `legacy nested JsonArray entries are skipped`() = testScope.runTest { + val legacyJson = """[["nested","array"],{"address":"1.2.3.4","name":"Good"}]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(1, result.size) + assertEquals("1.2.3.4", result[0].address) + } + + @Test + fun `legacy mixed array handles all element types`() = testScope.runTest { + // JsonPrimitive + valid JsonObject + malformed JsonObject + nested JsonArray + val legacyJson = """["10.0.0.1",{"address":"10.0.0.2","name":"Node"},{"name":"bad"},[1,2]]""" + val result = LegacyParsingHarness(legacyJson).recentAddresses.first() + + assertEquals(2, result.size) + assertEquals("10.0.0.1", result[0].address) + assertEquals("Meshtastic", result[0].name) + assertEquals("10.0.0.2", result[1].address) + } +} + +/** + * Test harness that mirrors the private legacy parsing logic of [RecentAddressesDataSource] without needing to bypass + * encapsulation. Exposes a [Flow] that emits the result of parsing a raw legacy JSON string using the same rules as the + * production fallback path. + */ +private class LegacyParsingHarness(private val rawJson: String) { + val recentAddresses: Flow> = flow { + val jsonArray = Json.parseToJsonElement(rawJson).jsonArray + emit( + jsonArray.mapNotNull { item -> + when (item) { + is JsonObject -> { + val address = item["address"]?.jsonPrimitive?.contentOrNull + val name = item["name"]?.jsonPrimitive?.contentOrNull + if (address != null && name != null) { + RecentAddress(address = address, name = name) + } else { + null + } + } + is JsonPrimitive -> { + item.contentOrNull?.let { RecentAddress(address = it, name = "Meshtastic") } + } + is JsonArray -> null + } + }, + ) + } +} diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index a1ba8fd63..966ab949a 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -25,7 +25,10 @@ kotlin { @Suppress("UnstableApiUsage") android { - androidResources.enable = true + androidResources { + enable = true + resourcePrefix = "meshtastic_" + } withHostTest { isIncludeAndroidResources = true } } diff --git a/core/resources/src/androidMain/res/raw/alert.mp3 b/core/resources/src/androidMain/res/raw/meshtastic_alert.mp3 similarity index 100% rename from core/resources/src/androidMain/res/raw/alert.mp3 rename to core/resources/src/androidMain/res/raw/meshtastic_alert.mp3 diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index cff4ec041..211e3b9c4 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -267,7 +267,8 @@ class MeshServiceNotificationsImpl( enableLights(true) enableVibration(true) setBypassDnd(true) - val alertSoundUri = "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.alert}".toUri() + val alertSoundUri = + "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.meshtastic_alert}".toUri() setSound( alertSoundUri, AudioAttributes.Builder() diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index 8d2045469..3054da6df 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -23,6 +23,7 @@ plugins { android { namespace = "org.meshtastic.feature.widget" + resourcePrefix = "widget_" defaultConfig { minSdk = 26 } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt index 6f988f2db..099b24cc3 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt @@ -132,11 +132,11 @@ class LocalStatsWidget : Scaffold( titleBar = { TitleBar( - startIcon = ImageProvider(R.drawable.app_icon), + startIcon = ImageProvider(R.drawable.widget_app_icon), title = stringResource(Res.string.meshtastic_app_name), actions = { CircleIconButton( - imageProvider = ImageProvider(R.drawable.ic_refresh), + imageProvider = ImageProvider(R.drawable.widget_ic_refresh), contentDescription = stringResource(Res.string.refresh), onClick = actionRunCallback(), backgroundColor = null, @@ -297,7 +297,7 @@ class LocalStatsWidget : CircularProgressIndicator(modifier = GlanceModifier.size(24.dp)) } else { Image( - provider = ImageProvider(R.drawable.app_icon), + provider = ImageProvider(R.drawable.widget_app_icon), contentDescription = null, modifier = GlanceModifier.size(32.dp), ) diff --git a/feature/widget/src/main/res/drawable/app_icon.xml b/feature/widget/src/main/res/drawable/widget_app_icon.xml similarity index 100% rename from feature/widget/src/main/res/drawable/app_icon.xml rename to feature/widget/src/main/res/drawable/widget_app_icon.xml diff --git a/feature/widget/src/main/res/drawable/ic_refresh.xml b/feature/widget/src/main/res/drawable/widget_ic_refresh.xml similarity index 100% rename from feature/widget/src/main/res/drawable/ic_refresh.xml rename to feature/widget/src/main/res/drawable/widget_ic_refresh.xml diff --git a/feature/widget/src/main/res/xml/local_stats_widget_info.xml b/feature/widget/src/main/res/xml/widget_local_stats_info.xml similarity index 100% rename from feature/widget/src/main/res/xml/local_stats_widget_info.xml rename to feature/widget/src/main/res/xml/widget_local_stats_info.xml From 76386e419c417edbfe34b5e85979e35a94913139 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:02:06 -0500 Subject: [PATCH 046/114] refactor: migrate remaining raw stateIn(WhileSubscribed) to stateInWhileSubscribed extension (#5113) --- .../org/meshtastic/feature/connections/ScannerViewModel.kt | 6 ++---- .../meshtastic/feature/node/detail/NodeDetailViewModel.kt | 5 ++--- .../org/meshtastic/feature/node/metrics/MetricsViewModel.kt | 6 ++---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 8ed5619cd..ccdc9ea24 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -20,7 +20,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.meshtastic.core.datastore.RecentAddressesDataSource @@ -108,7 +106,7 @@ open class ScannerViewModel( private val discoveredDevicesFlow = showMockTransport .flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + .stateInWhileSubscribed(initialValue = null) /** A combined list of bonded and scanned BLE devices for the UI. */ val bleDevicesForUi: StateFlow> = @@ -131,7 +129,7 @@ open class ScannerViewModel( } .flowOn(dispatchers.default) .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + .stateInWhileSubscribed(initialValue = emptyList()) /** UI StateFlow for USB devices. */ val usbDevicesForUi: StateFlow> = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 45b3cc2b8..733cd858c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -21,13 +21,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.DataPacket @@ -35,6 +33,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.UiText +import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.metrics.EnvironmentMetricsState @@ -81,7 +80,7 @@ class NodeDetailViewModel( if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState()) getNodeDetailsUseCase(nodeId) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState()) + .stateInWhileSubscribed(initialValue = NodeDetailUiState()) fun start(nodeId: Int) { if (manualNodeId.value != nodeId) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 8a051aaf2..3b6ea5656 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -23,7 +23,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull @@ -31,7 +30,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone @@ -106,7 +104,7 @@ open class MetricsViewModel( if (nodeId == null) return@flatMapLatest flowOf(MetricsState.Empty) getNodeDetailsUseCase(nodeId).map { it.metricsState } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MetricsState.Empty) + .stateInWhileSubscribed(initialValue = MetricsState.Empty) private val environmentState: StateFlow = activeNodeId @@ -114,7 +112,7 @@ open class MetricsViewModel( if (nodeId == null) return@flatMapLatest flowOf(EnvironmentMetricsState()) getNodeDetailsUseCase(nodeId).map { it.environmentState } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EnvironmentMetricsState()) + .stateInWhileSubscribed(initialValue = EnvironmentMetricsState()) private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) From 938a951737be15498679c40fa1014ef7aaec3c03 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:02:31 -0500 Subject: [PATCH 047/114] =?UTF-8?q?refactor:=20leverage=20CMP=201.11=20+?= =?UTF-8?q?=20Lifecycle=202.11=20=E2=80=94=20v2=20test=20API,=20Json=20pri?= =?UTF-8?q?vacy,=20dropUnlessResumed=20nav=20guards=20(#5112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DeviceHardwareJsonDataSourceImpl.kt | 1 + .../FirmwareReleaseJsonDataSourceImpl.kt | 1 + .../core/ui/component/AlertHostTest.kt | 2 +- .../core/ui/component/ImportFabUiTest.kt | 2 +- .../core/ui/util/AlertManagerUiTest.kt | 2 +- .../navigation/ConnectionsNavigation.kt | 8 +- .../feature/firmware/FirmwareRetriever.kt | 7 +- .../firmware/navigation/FirmwareNavigation.kt | 9 +- .../feature/firmware/ota/dfu/DfuZipParser.kt | 6 +- .../feature/map/navigation/MapNavigation.kt | 4 +- .../navigation/ContactsNavigation.kt | 12 ++- .../messaging/component/MessageItemTest.kt | 2 +- .../node/navigation/NodesNavigation.kt | 11 ++- .../settings/navigation/SettingsNavigation.kt | 91 ++++++++++++------- .../radio/channel/ChannelsNavigation.kt | 5 +- .../settings/debugging/DebugSearchTest.kt | 2 +- .../component/EditDeviceProfileDialogTest.kt | 2 +- .../component/MapReportingPreferenceTest.kt | 2 +- .../wifiprovision/domain/NymeaProtocol.kt | 3 + .../navigation/WifiProvisionNavigation.kt | 5 +- 20 files changed, 114 insertions(+), 63 deletions(-) diff --git a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSourceImpl.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSourceImpl.kt index 327cddcae..e20944f4e 100644 --- a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSourceImpl.kt +++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSourceImpl.kt @@ -32,6 +32,7 @@ class DeviceHardwareJsonDataSourceImpl(private val application: Application) : D private val json = Json { ignoreUnknownKeys = true isLenient = true + exceptionsWithDebugInfo = false } @OptIn(ExperimentalSerializationApi::class) diff --git a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSourceImpl.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSourceImpl.kt index c060f4b21..d437937d4 100644 --- a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSourceImpl.kt +++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSourceImpl.kt @@ -32,6 +32,7 @@ class FirmwareReleaseJsonDataSourceImpl(private val application: Application) : private val json = Json { ignoreUnknownKeys = true isLenient = true + exceptionsWithDebugInfo = false } @OptIn(ExperimentalSerializationApi::class) diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt index ab0f1a80f..7a442980f 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt @@ -19,7 +19,7 @@ package org.meshtastic.core.ui.component import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt index 650671de2..8380aabcb 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.proto.SharedContact diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt index 7d2e1d1a4..2090736b1 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import kotlin.test.Test import kotlin.test.assertTrue diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index 152e880cb..c6962c8c0 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -32,8 +32,8 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) ConnectionsScreen( scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, - onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, + onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onConfigNavigate = { route -> backStack.add(route) }, ) } @@ -42,8 +42,8 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) ConnectionsScreen( scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, - onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, + onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onConfigNavigate = { route -> backStack.add(route) }, ) } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index 64d550a79..1dcb7ba69 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.firmware import co.touchlab.kermit.Logger +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import org.meshtastic.core.database.entity.FirmwareRelease @@ -29,7 +30,11 @@ private const val FIRMWARE_BASE_URL = "https://raw.githubusercontent.com/meshtas /** OTA partition role in .mt.json manifests — the main application firmware. */ private const val OTA_PART_NAME = "app0" -private val manifestJson = Json { ignoreUnknownKeys = true } +@OptIn(ExperimentalSerializationApi::class) +private val manifestJson = Json { + ignoreUnknownKeys = true + exceptionsWithDebugInfo = false +} /** Retrieves firmware files, either by direct download or by extracting from a release asset zip. */ @Single diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt index 7980ad96a..40c6ad904 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.firmware.navigation import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -27,8 +28,12 @@ import org.meshtastic.feature.firmware.FirmwareUpdateViewModel /** Registers the firmware update screen entries into the Navigation 3 entry provider. */ fun EntryProviderScope.firmwareGraph(backStack: NavBackStack) { - entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } - entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } + entry { + FirmwareScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + } + entry { + FirmwareScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + } } @Composable diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt index 10a0a5154..43f6804e1 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt @@ -21,7 +21,11 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecodingException -private val json = Json { ignoreUnknownKeys = true } +@OptIn(ExperimentalSerializationApi::class) +private val json = Json { + ignoreUnknownKeys = true + exceptionsWithDebugInfo = false +} /** * Parse pre-extracted zip entries into a [DfuZipPackage]. diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index 2c0b5e7b8..8d2af9c4d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -26,8 +26,8 @@ fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current mapScreen( - { backStack.add(NodesRoute.NodeDetail(it)) }, // onClickNodeChip - { backStack.add(NodesRoute.NodeDetail(it)) }, // navigateToNodeDetails + { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip + { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails args.waypointId, ) } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index 0f347f980..62b57d3a8 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -61,9 +62,10 @@ fun EntryProviderScope.contactsGraph( contactKey = contactKey, message = args.message, viewModel = messageViewModel, - navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, - navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoute.QuickChat) }, - onNavigateBack = { backStack.removeLastOrNull() }, + navigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + navigateToQuickChatOptions = + dropUnlessResumed { backStack.add(org.meshtastic.core.navigation.ContactsRoute.QuickChat) }, + onNavigateBack = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -73,13 +75,13 @@ fun EntryProviderScope.contactsGraph( ShareScreen( viewModel = viewModel, onConfirm = { contactKey -> backStack.replaceLast(ContactsRoute.Messages(contactKey, message)) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } entry(metadata = { ListDetailSceneStrategy.extraPane() }) { val viewModel = koinViewModel() - QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) + QuickChatScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } } diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 68f7817aa..cf45cb1ec 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -19,7 +19,7 @@ package org.meshtastic.feature.messaging.component import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index facb5a9d7..778c8b220 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -19,6 +19,7 @@ package org.meshtastic.feature.node.navigation import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -116,9 +117,9 @@ fun EntryProviderScope.nodeDetailGraph( nodeId = destNum, viewModel = nodeDetailViewModel, compassViewModel = compassViewModel, - navigateToMessages = { backStack.add(ContactsRoute.Messages(it)) }, - onNavigate = { backStack.add(it) }, - onNavigateUp = { backStack.removeLastOrNull() }, + navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) }, + onNavigate = { route -> backStack.add(route) }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -128,7 +129,7 @@ fun EntryProviderScope.nodeDetailGraph( TracerouteLogScreen( viewModel = metricsViewModel, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, onViewOnMap = { requestId, responseLogUuid -> backStack.add( NodeDetailRoute.TracerouteMap( @@ -182,7 +183,7 @@ private inline fun EntryProviderScope.addNodeDetailS val metricsViewModel = koinViewModel { parametersOf(destNum) } metricsViewModel.setNodeId(destNum) - routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() } + routeInfo.screenComposable(metricsViewModel, dropUnlessResumed { backStack.removeLastOrNull() }) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 1409f6bdf..54f0f7100 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -106,7 +107,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { entry { DeviceConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) } @@ -117,13 +118,16 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { ModuleConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), excludedModulesUnlocked = excludedModulesUnlocked, - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) } entry { - AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() }) + AdministrationScreen( + viewModel = getRadioConfigViewModel(backStack), + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + ) } entry { @@ -135,16 +139,26 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { configComposable(routeInfo.route::class, backStack) { viewModel -> LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } when (routeInfo) { - ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.DEVICE -> DeviceConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POSITION -> PositionConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.SECURITY -> SecurityConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.USER -> + UserConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.CHANNELS -> + ChannelConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.DEVICE -> + DeviceConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.POSITION -> + PositionConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.POWER -> + PowerConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.NETWORK -> + NetworkConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.DISPLAY -> + DisplayConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.LORA -> + LoRaConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.BLUETOOTH -> + BluetoothConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.SECURITY -> + SecurityConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } } @@ -153,50 +167,63 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { configComposable(routeInfo.route::class, backStack) { viewModel -> LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } when (routeInfo) { - ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.MQTT -> + MQTTConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.SERIAL -> + SerialConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreenCommon( viewModel = viewModel, - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, ) ModuleRoute.STORE_FORWARD -> - StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + StoreForwardConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.RANGE_TEST -> + RangeTestConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.TELEMETRY -> + TelemetryConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.CANNED_MESSAGE -> - CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + CannedMessageConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.AUDIO -> + AudioConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.REMOTE_HARDWARE -> - RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + RemoteHardwareConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.NEIGHBOR_INFO -> - NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + NeighborInfoConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.AMBIENT_LIGHTING -> - AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + AmbientLightingConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.DETECTION_SENSOR -> - DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + DetectionSensorConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.PAXCOUNTER -> + PaxcounterConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.STATUS_MESSAGE -> - StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + StatusMessageConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.TRAFFIC_MANAGEMENT -> - TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + TrafficManagementConfigScreen( + viewModel, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + ) + ModuleRoute.TAK -> + TAKConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } } entry { val viewModel: DebugViewModel = koinViewModel() - DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) + DebugScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } entry { - AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }, jsonProvider = { getAboutLibrariesJson() }) + AboutScreen( + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + jsonProvider = { getAboutLibrariesJson() }, + ) } entry { val viewModel: FilterSettingsViewModel = koinViewModel() - FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() }) + FilterSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt index f73b6b731..8ec5f593e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.settings.radio.channel +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -29,7 +30,7 @@ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) { ChannelScreen( radioConfigViewModel = koinViewModel(), onNavigate = { route -> backStack.add(route) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -37,7 +38,7 @@ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) { ChannelScreen( radioConfigViewModel = koinViewModel(), onNavigate = { route -> backStack.add(route) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt index f68a79f23..83bcddee1 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.dp import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_active_filters diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt index 61d3b1219..cffeab006 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.getString diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt index 850cc93e7..42a67a6a0 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.i_agree diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt index 2519595d1..71fe68f79 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.wifiprovision.domain +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -33,9 +34,11 @@ import kotlinx.serialization.json.Json // Shared JSON codec — lenient so unknown fields are silently ignored // --------------------------------------------------------------------------- +@OptIn(ExperimentalSerializationApi::class) internal val NymeaJson = Json { ignoreUnknownKeys = true isLenient = true + exceptionsWithDebugInfo = false } // --------------------------------------------------------------------------- diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt index ea30112c7..a79d32b25 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.wifiprovision.navigation +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -31,9 +32,9 @@ import org.meshtastic.feature.wifiprovision.ui.WifiProvisionScreen */ fun EntryProviderScope.wifiProvisionGraph(backStack: NavBackStack) { entry { - WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }) + WifiProvisionScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } entry { key -> - WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }, address = key.address) + WifiProvisionScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, address = key.address) } } From 8e7c4f54a39d5ee3b54aa6d2a58cdb010ca75eac Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:24:43 -0500 Subject: [PATCH 048/114] chore(deps): update actions/upload-pages-artifact action to v5 (#5114) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index faa9ff3c3..f7c8151c7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -66,7 +66,7 @@ jobs: run: ./gradlew dokkaGeneratePublicationHtml - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: build/dokka/html From 92166f0fa210ff0c1b857a644f0e64cf5ee84876 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:52:55 -0500 Subject: [PATCH 049/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5115) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-ro/strings.xml | 592 +++++++++++++++++- 1 file changed, 581 insertions(+), 11 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index 8206e5aaf..440302ec3 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -35,11 +35,15 @@ Ultima recepție via MQTT via MQTT + Intern după favorite Arată doar nodurile ignorate + Exclude MQTT Nerecunoscut În așteptarea confirmării În coadă pentru trimitere + Livrat la Mesh + Necunoscut Rutare prin lanțul SF++… Confirmat pe lanțul SF++ Confirmat @@ -89,6 +93,9 @@ Busola de pe ecran, în afara cercului, va indica întotdeauna nordul. Rotire ecran vertical. Unitățile afișate pe ecranul dispozitivului. + Suprascrie ecranul OLED automat. + Suprascrie aspectul implicit al ecranului. + Îngroşează textul din antet de pe ecran. Necesită ca dispozitivul dvs. să aibă un accelerometru. Regiunea în care veți folosi radioul. Presetările modemului disponibile, implicit este Long Fast (Rază lungă - rapid). @@ -110,9 +117,10 @@ Intervalul maxim care poate trece fără ca un nod să transmită o poziție. Cea mai rapidă actualizare a poziției care va fi trimisă dacă distanța minimă a fost respectată. Modificarea minimă a distanței în metri care trebuie luată în considerare pentru o transmisie inteligentă a poziției. - Cât de des ar trebui să se încerce obținerea locației GPS (<10 secunde menține GPS-ul activ). + Cât de des ar trebui să încercăm să obținem o poziție GPS (<10sec păstrează GPS activat). Câmpuri opționale care trebuie incluse la asamblarea mesajelor de poziție. Cu cât sunt incluse mai multe câmpuri, cu atât mesajul va fi mai mare, ceea ce va duce la un timp de transmisie mai lung și la un risc mai mare de pierdere a pachetelor. Va păstra totul în repaus cât mai mult posibil, pentru rolul de tracker și senzor, aceasta va include și radioul LoRa. Nu utilizați această setare dacă doriți să utilizați dispozitivul cu aplicațiile telefonului sau dacă utilizați un dispozitiv fără buton de utilizator. + Generată din cheia publică și trimisă către alte noduri din rețea pentru a le permite să calculeze o cheie secretă comună. Utilizat pentru a crea o cheie partajată cu un dispozitiv la distanță. Cheia publică autorizată să trimită mesaje de administrare către acest nod. Dispozitivul este gestionat de un administrator de rețea, utilizatorul neputând accesa niciuna dintre setările dispozitivului. @@ -151,7 +159,7 @@ Distribuie Nod nou găsit: %1$s Deconectat - Dispozitiv în sleep mode + Adormirea dispozitivului Adresa IP: Port: Conectat @@ -161,14 +169,22 @@ Conectare Neconectat Nici un dispozitiv selectat + Dispozitiv necunoscut + Nici un dispozitiv de rețea găsit + Niciun dispozitiv USB găsit + USB + Mod demonstrativ Connectat la dispozitivi, dar e în modul de sleep Aplicație prea veche Trebuie să updatezi această aplicație de pe Google Play (sau Github). Aplicația este prea veche pentru a comunica cu dispozitivul. Niciunul (dezactivat) Notificările serviciului Mulțumiri + Biblioteci open source + Meshtastic este construit cu următoarele biblioteci open source. Atingeți orice bibliotecă pentru a vedea licența. + Librării %1$d Acest URL de canal este invalid și nu poate fi folosit - Panou debug + Panou de depanare Date decodate: Export jurnale %1$d (de) jurnale exportate @@ -194,14 +210,28 @@ Ștergeți toate filtrele Adăugare filtru personalizat Presetări filtre - Salvează jurnalele din mesh - Dezactivați pentru a omite scrierea jurnalelor din mesh pe disc + Salvează jurnalele din retea + Dezactivați pentru a omite scrierea jurnalelor din retea pe disc Ștergeți jurnalele Potrivire oricare | toate Potrivire toate | oricare Aceasta va șterge toate pachetele de jurnal și intrările din baza de date de pe dispozitivul dvs. - Este o resetare completă și este permanentă. Șterge + Căutare emoji-uri... + Mai multe reacţii Canal + %1$s:%2$s + Mesaj de la %1$s %2$s + Antet + Obiect %1$d + Subsol + Casetă + Bulină + Text + Indicator + Degrade + Acesta este un element compozabil personalizat + Cu mai multe linii şi stiluri Status livrare mesaj Mesaje noi mai jos Notificări mesaje directe @@ -227,6 +257,7 @@ Setarea telefonului Alege tema Furnizați locația telefonului la mesh + Codare compactă pentru chirilică Ștergeți mesajul? Ștergeți %1$s mesaje? @@ -252,7 +283,7 @@ ⚠️ Aceasta va OPRI nodul. Pentru a-l repune în funcțiune, va fi necesară o intervenție fizică. Nod: %1$s Restartează - Traceroute + Trasare traseu Arată Introducere Mesaj Opțiuni chat rapid @@ -269,6 +300,7 @@ Mesaj direct Resetare NodeDB Livrare confirmată + Dispozitivul dumneavoastră se poate deconecta şi reporni în timp ce setările sunt aplicate. Eroare Ignoră Eliminați din lista ignorate @@ -310,6 +342,8 @@ În prezent: Mereu silențios Nu este silențios + Silențios pentru %1$d zile, %2$s ore + Silențios pentru %1$s ore Dezactivați notificările pentru „%1$s”? Activați notificările pentru '%1$s'? Înlocuire @@ -319,6 +353,10 @@ Baterie ChUtil AirUtil + %1$s: %2$.1f%% + %1$s: %2$.1f V + %1$.1f + %1$s:%2$s Temp Hum Temp sol @@ -374,11 +412,22 @@ Durată: %1$s s Ruta trasată spre destinație:\n\n Ruta urmărită înapoi la noi:\n\n + Redirecționare Hops + Hops de returnare + Dus-întors + Niciun raspuns + Încărcare 1m + Încărcare 5m + Încărcare 15m + Încărcătura medie a sistemului de un minut Media de încărcare sistem de cinci minute 24H 1W 2W Maxim + Medie + Extindeți graficul + Restrânge graficul Vârstă necunoscută Copiere Caracter clopoțel de alertă! @@ -392,11 +441,17 @@ Canalul 1 Canalul 2 Canalul 3 + Canalul 4 + Canalul 5 + Canalul 6 + Canalul 7 + Canalul 8 Actual Tensiune Sunteți sigur? Documentația privind rolul dispozitivului și articolul de blog despre Alegerea rolului potrivit pentru dispozitiv.]]> Știu ce fac. + Nodul %1$s are bateria descărcată (%2$d%) Notificări pentru baterii descărcate Baterie descărcată: %1$s Notificări pentru baterii descărcate (noduri favorite) @@ -527,9 +582,21 @@ LoRa Opțiuni Avansate + Utilizare presetare Presetări Lățime bandă + Factor de răspândire + Rata de codificare Regiune + Numărul de Hops + Transmisie activată + Putere transmisie + Slot pentru frecvenţă + Suprascrie ciclul de obligații + Ignoră primirea + Amplificare RX amplificată + Suprascriere frecvență + Ventilator PA dezactivat Ignoră MQTT Acceptă MQTT Configurare MQTT @@ -540,63 +607,566 @@ Criptare activată Ieșire JSON activată TLS activat + Temă rădăcină + Proxy-ul pentru client activat + Raportarea hărții + Intervalul de raportare hartă (secunde) + Configurare informații vecin + Info vecin activat + Interval de actualizare (secunde) + Transmite peste LoRA + Optiuni Wi-Fi Activat + WiFi activat + Numele rețelei + PSK + Opţiuni Ethernet + Ethernet activat + Server NTP + server rsyslog + Mod IPv4 + IP + Poartă de acces + Subred + DNS Configurație Paxcounter Paxcounter activat + Mesaj de stare: + Configurare mesaj prestabilit + Șirul de stare real Pragul WiFi RSSI (implicit la -80) + Latitudine + Longitudine + Setează din locația curentă a telefonului + Mod GPS (hardware fizic) + Steaguri poziție + Configurare Putere + Activează modul de economisire a energiei + Închidere la pierderea de energie + Suprascriere multiplicator ADC + Raportul suprascrierii multiplicatorului ADC + Așteptați pentru durata Bluetooth + Durată maximă de somn + Durata minimă a trezirii + Adresa baterie INA_2XX I2C + Configurare test interval + Testul de gamă activat + Interval mesaj expeditor (secunde) + Salvați .CSV doar în memorie (ESP32) + Configurare hardware la distanță + Hardware extern activat + Permite acces Pin nedefinit + Pin-uri disponibile + Mesaj direct + Chei Admin + Chei publice + Cheia privată + Cheie Administrator + Mod Gestionat + Consolă serială + Debug log API activat + Canal implicit de administrator + Configurație serial + Serial activat + Echo activat + Rata baud-ului serial + RX + TX Expirat + Mod serial + Suprascrie portul serial al consolei - Valorile mediului utilizează Fahrenheit + Puls + Numarul de inregistrari + istoric număr maxim de retur + Fereastra de returnare a istoricului + Server + Configurare telemetrie + Intervalul de actualizare a parametrilor dispozitivului + Interval actualizare valori mediu + Modul de măsurare mediu activat + Valorile de mediu pe ecran sunt activate + Valorile de mediu utilizează Fahrenheit + Interval actualizare măsurători de calitate a aerului + Pictograma calităţii aerului + Modul de măsurare putere activat + Interval actualizare măsurători de putere + Valori pe ecran activate + Configurare utilizator + ID-ul Nodului Nume lung Nume scurt Model hardware + Radioamator autorizat + Activarea acestei opțiuni dezactivează criptarea și nu este compatibilă cu rețeaua implicită de Meshtastic. Punct de rouă Presiune + Rezistența la gaz Distanță + Lux Vânt + Viteza vântului + Viteza rafale + Vânt critic + Directie vânt + Ploaie (1h) + Ploaie (24h) Greutate Radiație + Calitatea aerului interior (IAQ) URL + + Importă configurația + Exportă configurația + Dispozitive + Suportate + Număr modul + ID utilizator + Timp de functionare + Încărcare %1$d + Disc liber %1$d + Data si ora + Direcție + Viteza + %1$d Km/h + Sateliți + Alt + Frecvență + Slot + Primară + Poziție periodică și transmisiune telemetrică + Secundar + Nicio transmisiune periodică telemetrie + Solicitarea de poziție manuală este necesară + Apăsați și trageți pentru a reordona + Activare sunet + Dinamic + Împărtășește contacte + Notițe + Adaugă o notiță privată + Importați contactul partajat? + Netransmisibil Nemonitorizată sau infrastructură - + Cheie publică schimbată + Importa + Solicitare + Se solicită %1$s de la %2$s + Informații utilizator + Solicită telemetrie Valori dispozitiv Indicatori de mediu + Calitatea aerului, valoare Valori putere + Valori Gazdă + Valori Pax + Metadate + Acţiuni + Firmware + Utilizaţi formatul ceasului 12h + Când este activat, dispozitivul va afișa pe ecran ora în format 12 ore. + Valori Gazdă + Gazdă + Memorie Liberă + Încarcă + Șir Utilizator + Navigați în + Conexiune + Harta retea + Conversații + Noduri + Setări + Selectat + Setează-ți regiunea + Răspunde + Nodul tău va trimite periodic un pachet de rapoarte de hărți necriptate serverului MQTT configurat, acesta include un nume id, lung și scurt, aproximează locația, modelul hardware, rolul, versiunea firmware, regiunea LoRa, presetarea modemului și numele canalului primar. + Consimțământ pentru a Partaja date Node necriptate prin MQTT + Prin activarea acestei caracteristici, acceptați și consimți în mod expres transmiterea locației geografice în timp real a dispozitivului dvs. peste protocolul MQTT fără criptare. Aceste date de localizare pot fi utilizate în scopuri cum ar fi raportarea hărților live, urmărirea dispozitivelor și funcțiile telemetrice aferente. + Am citit şi înţeleg cele de mai sus. Sunt de acord voluntar cu transmiterea necriptată a datelor nodului prin MQTT + Sunt de acord + Actualizare firmware recomandată. + Pentru a beneficia de cele mai recente remedieri și caracteristici, vă rugăm să vă actualizați firmware-ul node.\n\nUltima versiune de firmware stabilă: %1$s + Expiră la + Timp + Dată + Filtru Hartă\n + Doar Favorite Arată repere - Ești sigur că vrei să-ți regenerezi cheia privata?\n\nNodurile care ar fi putut schimba anterior chei cu acest modul vor trebui să elimine acel nod şi să schimbe din nou cheile pentru a relua comunicarea securizată. + Arată cercuri de precizie + Notificare client + Verificare cheie + Solicitare de verificare cheie + Verificare cheie finalizată + Duplicat Cheie Publică detectată + Cheie Criptare slabă detectată + Chei promise detectate, selectaţi OK pentru regenerare. + Regenerează Cheia privată + Sunteţi sigur că doriţi să vă regeneraţi cheia privată?\n\nNodurile care ar fi putut schimba anterior chei cu acest modul vor trebui să elimine acel nod și să schimbe din nou tastele pentru a relua comunicarea securizată. + Modulele deblocate + Modulele sunt deja deblocate + De la distanta (%1$d online / %2$d afișate / %3$d în total) + Reacţionează + Deconectați + Derulare până jos Meshtastic + Stare de securitate + Securizare + Insigna de avertizare + Canal necunoscut. + Avertizare + Meniu de Overflow + LUX UV + Necunoscut + Acest radio este gestionat și poate fi schimbat doar de un administrator de la distanță. Avansate + Curăță baza de date a nodurilor + Curăță nodurile văzute ultima dată mai vechi de %1$d zile + Curăță doar noduri necunoscute + Curăţă acum + Acest lucru va elimina %1$d noduri din baza ta de date. Această acțiune nu poate fi anulată. + O blocare verde înseamnă că canalul este criptat în siguranță cu o cheie de 128 sau 256 bit AES. + Canalul nesigur, nu este exact + Blocare deschisă galbenă înseamnă că canalul nu este criptat în siguranţă, nu este utilizat pentru date precise privind localizarea și nu utilizează nicio cheie sau o cheie cunoscută de 1 octet. + Canal nesigur, precizie locație + Blocarea roşie înseamnă că canalul nu este criptat în siguranţă, se utilizează pentru date precise privind localizarea și nu se utilizează nici o cheie sau o cheie citată. + Atenție: Locație nesigură, precisă & MQTT Uplink + Un lacăt roșu deschis cu un avertisment înseamnă că canalul nu este criptat în siguranță este utilizat pentru date precise privind localizarea care sunt conectate la internet prin intermediul MQTT, şi nu foloseşte nici o cheie sau o cheie cunoscută. + Securitate canal + Mijloace de securitate canale + Afișați toate mijloacele + Arată statusul actual + Renunțați + Răspunde la %1$s + Anulați răspunsul + Ștergeți mesajul? + Șterge selecția Mesaj + Scrie un mesaj + Măsurători PAX + PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s + Nu sunt disponibile măsurători PAX. + Wi-Fi Provisioning for mPWRD-OS + Dispozitive Bluetooth + Dispozitive conectate Rata limită depășită. Te rugăm să încerci din nou mai târziu. - Administreaza Layers Hartă + Descărcare + Instalate in acest moment + Ultimul stabil + Ultimul alfa + Sprijinită de comunitatea Meshtastic + Ediţie firmware + Dispozitive recente de rețea + Dispozitive ale rețelei descoperite + Dispozitive bluetooth disponibile + Să începem + Bine ai venit la + Rămâneţi conectat oriunde + Comunicați în afara grilei cu prietenii și comunitatea dvs. fără serviciu celular. + Creează-ţi propriile reţele + Înființarea cu ușurință a rețelelor private de plasare pentru comunicații sigure și fiabile în zonele îndepărtate; + Urmăriți și partajați locațiile + Partajați-vă locația în timp real și păstrați grupul coordonat cu funcții GPS integrate. + Notificări aplicații + Mesaje primite + Notificări pentru canal și mesaje directe. + Noduri Noi + Notificări pentru nodurile recent descoperite. + Baterie descarcata + Notificări pentru alerte de baterie scăzută pentru dispozitivul conectat. + Configurați permisiunile pentru notificări + Locaţia telefonului + Meshtastic folosește locația telefonului tău pentru a activa o serie de caracteristici. Poți actualiza permisiunile de locație în orice moment din setări. + Partajați locația + Folosește GPS-ul telefonului pentru a trimite locații către nodul tău în loc să folosești un GPS hardware de pe nodul tău. + Măsurătorile distanței + Afișează distanța dintre telefonul dvs. și alte noduri Meshtastice cu poziții. + Filtre distanță + Filtrează lista de noduri și harta plasei în funcție de proximitatea telefonului tău. + Locație hartă plasa + Activează punctul albastru pentru telefon în harta plasei. + Configurare permisiuni locație + Treci peste + setari + Alerte critice + Pentru a te asigura că primești alerte critice, cum ar fi mesajele + SOS, chiar și atunci când dispozitivul este în modul \"Nu deranja\" trebuie să acorzi permisiunea specială + . Vă rugăm să activați acest lucru în setările notificărilor. + + Configurează alertele critice + Meshtastic folosește notificări pentru a te ține la curent cu mesaje noi și alte evenimente importante. Puteți actualiza permisiunile de notificare în orice moment din setări. + Următor + %1$d noduri aflate în așteptare pentru ștergere: + Atenție: Acest lucru elimină nodurile din bazele de date in-app și on-device.\nSelecțiile sunt aditive. + Normal + Prin satelit + Teren + Hibridă + Gestionează Layers Hartă + Nu s-au încărcat straturi de hărți. + Ascunde Layer + Arată Layer + Elimină strat + Adăugați un strat + Noduri în această locație + Tipul hărții selectate + Gestionează surse personalizate de stil + Adaugă sursă de rețea Tile + Nu s-au găsit surse de comutare personalizată. + Modifică sursa rețelei + Ştergeţi sursa de reţea + Numele nu poate fi gol + Nume furnizor exista. + Adresa URL nu poate fi goală. + URL-ul trebuie să conţină substituenţi. + Şablon URL + punct de traseu + Aplicaţie + Versiune + Funcții canal + Partajarea locației + Pozitie periodica + Mesajele de la mesageria va fi trimise pe internet public prin intermediul oricărui portal configurat de nod. Mesajele provenite de la o un gateway public de internet sunt redirecționate către rețeaua locală. Datorită politicii de zero salturi, traficul provenit de la serverul MQTT implicit nu se va propaga mai departe de acest dispozitiv. + Semne pictograme + Dezactivarea poziției pe canalul primar permite transmisii periodice de poziție pe primul canal secundar cu poziția activată, altfel solicitarea manuală a poziției este necesară. + Configurare dispozitiv + "[Remote] %1$s" + Trimite telemetrie dispozitiv Activează/dezactivează modulul de telemetrie al dispozitivului pentru a trimite metrici către rețeaua mesh. Acestea sunt valori nominale. Rețelele mesh congestionate se vor scala automat la intervale mai lungi, în funcție de numărul de noduri online. - O oră + Oricare + 1 Oră 8 Ore 24 Ore 48 Ore + Filtrați după ultima oră: %1$s + %1$d dBm + Setări ale sistemului + Nici o statistică disponibilă + Analytics sunt colectate pentru a ne ajuta să îmbunătățim aplicația Android (mulțumesc), vom primi informații anonime despre comportamentul utilizatorului. Aceasta include rapoarte de accidente, ecrane folosite în aplicație, etc. + Platforme analitice: + Pentru mai multe informații, consultați politica noastră de confidențialitate. + Nesetat - 0 + %1$s de obicei este livrat cu un bootloader care nu acceptă actualizări OTA. Este posibil să fie nevoie să instalați un bootloader OTA prin USB înainte de a instala OTA. + Pentru RAK WisBlock RAK4631, folosiți unealta DFU serială's (de exemplu, seria adafruit-nrfutil dfu cu bootloader furnizat. fișier ip). Copierea fișierului .uf2 nu va actualiza bootloader-ul singur. + Don't arată din nou pe acest dispozitiv + Păstrează favoritele? + Actualizare firmware + Căutare actualizări... + Dispozitiv: %1$s + Instalat în prezent: %1$s + Actualizare către: %1$s Stabil + Notă: Aceasta va deconecta temporar dispozitivul dumneavoastră în timpul actualizării. + Se descarcă firmware... %1$d% + Eroare: %1$s + Reîncercați + Actualizare reușită! + Gata + Se pornește DFU... + Se activează modul DFU... + Se validează firmware-ul... + Model hardware necunoscut: %1$d + Niciun dispozitiv conectat + Nu am putut găsi firmware-ul pentru %1$s în versiune + Extragere firmware... Actualizare eșuată + lucrăm la acest lucru... + Ţineţi dispozitivul aproape de telefon. + Nu închideți aplicația. + Aproape gata... + Acest lucru ar putea dura un minut... + Selectare fișier local + Fișier local + Sursa: Fișier Local + Lansare la distanţă necunoscută + Avertisment actualizare + Sunteți pe cale să instalați firmware-ul nou pe dispozitiv. Acest proces poartă riscuri.\n\n• Asigurați-vă că aparatul este încărcat.\n• Țineți dispozitivul aproape de telefon.\n• Nu închideți aplicația în timpul actualizării.\n\nVerificați că ați selectat firmware-ul corect pentru hardware-ul dumneavoastră. + Chirpy spune, \"Ţineţi-vă scara la îndemână!\" + Chirpy + Repornirea pe DFU... + High-cinci! Așteptați, copiere firmware-ul... + Vă rugăm să salvaţi fişierul .uf2 pe dispozitivul dvs's DFU unitate. + Atașare dispozitiv, vă rog așteptați... + Transfer fişier USB + BLE OTA + WiFi OTA + Updateaza către %1$s + Selectați DFU USB disk + Dispozitivul dvs. a repornit în modul DFU şi ar trebui să apară ca un dispozitiv USB (de exemplu, RAK4631).\n\nCând se deschide selectorul de fişiere, vă rugăm să selectaţi rădăcina acelui disc pentru a salva fişierul de firmwar. + Verific actualizarea... + Verificarea a expirat. Dispozitivul nu a reconectat în timp. + Se așteaptă ca dispozitivul să se reconecte... + Target: %1$s + Note de lansare + Eroare necunoscuta + Informațiile utilizatorului nodului lipsesc. + Baterie prea mică (%1$d%). Vă rugăm să încărcați dispozitivul înainte de actualizare. + Nu s-a putut recupera fișierul de firmware. + Actualizare USB nereuşită + Hash firmware respins. Dispozitivul poate avea nevoie de provizioane hash sau actualizare bootloader + Actualizare OTA esuata: %1$s + Se așteaptă ca dispozitivul să se repornească în modul OTA... Conectarea la dispozitiv (încercarea %1$d/%2$d)... + Încărcare firmware... + Ştergere... + Înapoi Nesetat + Mereu pornit %1$d oră %1$d ore %1$d de ore + Busolă + Deschide busola + Distanță: %1$s + Bearing: %1$s + Acest dispozitiv nu are un senzor de busolă. Heading este indisponibil. + Este necesară permisiunea de localizare pentru a afișa distanța și rularea. + Furnizorul de localizare este dezactivat. Porniți serviciile de localizare + Se așteaptă o rezolvare GPS-ul pentru a calcula distanța și rularea. + Suprafață estimată: \u00b1%1$s (\u00b1%2$s) + Zonă estimată: precizie necunoscută + Marchează ca Citit + Acum + Următoarele canale au fost găsite în codul QR. Selectaţi o dată ce doriţi să adăugaţi pe dispozitivul dumneavoastră. Canalele existente vor fi păstrate. + Acest cod QR conţine o configuraţie completă. Aceasta va REPLACE canalele existente şi setările radio existente. Toate canalele existente vor fi eliminate. + Încărcare + Filtru mesaje + Activați filtrarea + Ascunde mesajele ce conțin cuvinte filtre + Filtrare cuvinte + Mesajele ce conţin aceste cuvinte vor fi ascunse + Adaugă cuvânt sau regex:pattern-ul + Nici un filtru cuvinte configurate + Model regex + Cuvânt întreg se potrivește + Arata %1$d filtrate + Ascunde %1$d filtrate + Filtrat + Activați filtrarea + Dezactivați filtrarea + Adresa canalului + Scanați NFC + Scanare contacte partajate NFC + Scanare cod QR contacte partajat + Introducere adresă contact partajată + Scanare canale NFC + Scanează canale cod QR + Introduceți URL-ul canalului + Distribuie codul QR al canalelor + Aduceți dispozitivul aproape de tag-ul NFC pentru a scana. + Generați codul QR + NFC este dezactivat. Vă rugăm să îl activați în setările de sistem. Toate Bluetooth + Configuraţi permisiunile Bluetooth + Descoperiți + Gestionați fără fir setările și canalele dispozitivului dvs. + Selecție stil hartă + Baterie: %1$d%% + Noduri: %1$d online / %2$d total + Actualizare: %1$s + ChUtil: %1$s% | AirTX: %2$s% + Trafic: TX %1$d / RX %2$d (D: %3$d) + Relee: %1$d (Canceled: %2$d) + Diagnosticuri: %1$s + Zgomotul %1$d dBm + Greșit %1$d + A pierdut %1$d + Titlu + %1$d / %2$d + %1$s + Alimentare + Reimprospatare + Actualizat + Adaugă nivel rețea + Fișier local MBTiles + Adaugă fișier MBTiles local + TAK (ATAK) + Configurare TAK + Activare server TAK local + Pornește un server TCP pe portul 8089 pentru conexiunile ATAK + Culoarea echipei + Rolul membrului + Nespecificat + Alb + Galben + Portocaliu + Mov Roșu + Maro + Violet + Albastru închis Albastru + Azuriu + Albastru-verzui Verde + Verde închis + Maro + Nespecificat + Membrii Echipei + Lider de echipă + Sediul Principal + Lunetist + Medic + Retrimite observatorul + Operator Radio Telefon + caine + Gestionare trafic + Modul activat + Deduplicare poziție + Precizie poziție (bits) + Interval poziţie minimă (sec) + NodeInfo Răspuns direct + Hops maxim pentru răspuns direct + Evaluare limitare + Evaluează fereastra limită (secunde) + Pachete Max în fereastră + Plasează pachete necunoscute + Prag de pachet necunoscut + Telemetrie doar local + Poziție doar-locală (raioane) + Păstrează Hops Router + Notiță + Dispozitiv de stocare & UI (doar cu permisiune) + Tema %1$s, Limba %2$s + Fișiere disponibile (%1$d): + - %1$s (%2$d bytes) + Nici un fişier manifestat. + Conectare + Gata + Wi-Fi Provisioning for mPWRD-OS + Furnizați acreditări Wi-Fi pentru dispozitivul mPWRD-OS prin Bluetooth. + Aflați mai multe despre proiectul mPWRD-OS\nhttps://github.com/mPWRD-OS + Căutare dispozitive + Dispozitiv gasit + Gata de scanare pentru rețele WiFi. + Scanare pentru reţele + Scanare… + Se aplică configurarea WiFi… + Nu au fost găsite rețele + Nu se poate conecta: %1$s + Nu s-a reușit scanarea pentru rețelele WiFi: %1$s + %1$d% + Rețele disponibile + Nume rețea (SSID) + Introdu sau selecteaza o retea + WiFi configurat cu succes! + Nu s-a reușit aplicarea configurației Wi-Fi From 28be6933c81d6318b87ba1a9c3c4ad8258d9e185 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:55:52 -0500 Subject: [PATCH 050/114] fix(proguard): disable shrinking for Compose animation classes (#5116) --- app/proguard-rules.pro | 16 +++++----------- desktop/README.md | 2 +- desktop/proguard-rules.pro | 7 +++---- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 7feaa9217..f504e7bb6 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -48,14 +48,8 @@ # curves, transition specs, Animatable internals) which can cause animations to # silently snap in release builds. # -# -keep prevents class merging (EnterTransition/ExitTransition into *Impl, -# VectorizedSpringSpec/TweenSpec elimination, etc.). -# allowshrinking lets R8 remove genuinely unreachable classes (e.g. -# SharedTransition APIs, RepeatableSpec — unused by this app). Verified via -# dex analysis: 278 classes survive in release vs 139 without this rule; -# all actively used classes (AnimatedVisibility, Crossfade, SpringSpec, -# TweenSpec, EnterTransition, ExitTransition, etc.) are preserved. -# allowobfuscation is moot (-dontobfuscate is set above) but explicit for -# clarity. -# The ** wildcard is recursive and covers animation.core.* sub-packages. --keep,allowshrinking,allowobfuscation class androidx.compose.animation.** { *; } +# We use a full -keep here without allowshrinking/allowobfuscation. While it +# might keep some unused transition APIs, R8's aggressive shrinking is known +# to incorrectly remove internal states or merging empty transitions (like None) +# causing AnimatedVisibility and others to snap. +-keep class androidx.compose.animation.** { *; } diff --git a/desktop/README.md b/desktop/README.md index 491e9fe68..975cd59e2 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -32,7 +32,7 @@ Release builds use ProGuard for tree-shaking (unused code removal), significantl - `proguard-rules.pro` — Keep-rules for reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP `androidx.room3`, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources) and an anti-merge rule for Compose animation classes. **Key rules:** -- **Compose animation anti-merge** (`-keep,allowshrinking,allowobfuscation class androidx.compose.animation.** { *; }`) — Prevents ProGuard's optimizer from merging animation class hierarchies (e.g. `EnterTransition`/`ExitTransition` into `*Impl`), which causes animations to silently snap. Same rule as Android. +- **Compose animation anti-merge** (`-keep class androidx.compose.animation.** { *; }`) — Prevents ProGuard's optimizer from incorrectly tree-shaking or merging animation class hierarchies (e.g. `EnterTransition`/`ExitTransition` into `*Impl`), which causes animations to silently snap. Same rule as Android. - **Room KMP** — Uses `androidx.room3` package path (Room KMP 3.x). **Troubleshooting ProGuard issues:** diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index b4e6cc451..3a074d9ac 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -150,10 +150,9 @@ # ---- Compose Animation (anti-merge) ---------------------------------------- # Prevent ProGuard from merging animation spec class hierarchies (same issue -# as R8 on Android — EnterTransition/ExitTransition merged into *Impl, -# VectorizedSpringSpec/TweenSpec eliminated). allowshrinking lets ProGuard -# remove genuinely unreachable classes. --keep,allowshrinking,allowobfuscation class androidx.compose.animation.** { *; } +# as R8 on Android). We use a full keep to prevent incorrect tree-shaking +# of internal transitions. +-keep class androidx.compose.animation.** { *; } # ---- AboutLibraries --------------------------------------------------------- From 27367e906487740687ec640dc4adc614fd5b6cef Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:32:00 -0500 Subject: [PATCH 051/114] fix(build): pin Skiko version to align with Compose Multiplatform (#5117) --- .../org/meshtastic/buildlogic/KotlinAndroid.kt | 15 +++++++++++++++ gradle/libs.versions.toml | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index bcc6d0207..c7afeaf39 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -72,6 +72,21 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) { /** Configure Kotlin Multiplatform options */ internal fun Project.configureKotlinMultiplatform() { + // Skiko is an internal CMP implementation detail; third-party KMP libraries + // (e.g. coil3) can carry an older skiko transitive requirement that Gradle + // upgrades to the CMP-bundled version, triggering a "Skiko dependencies' + // versions are incompatible" warning from CMP's compatibility checker. + // Force the version to match CMP so the checker sees a consistent graph. + val skikoVersion = libs.version("skiko") + configurations.configureEach { + resolutionStrategy.eachDependency { + if (requested.group == "org.jetbrains.skiko") { + useVersion(skikoVersion) + because("Align Skiko with the version bundled by Compose Multiplatform") + } + } + } + extensions.configure { // Standard KMP targets for Meshtastic jvm() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ae325188..230e6533f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,11 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" +# Skiko is an internal CMP implementation detail. Pin it to the version shipped by CMP to +# silence the "Skiko dependencies' versions are incompatible" warning emitted when transitive +# dependencies (e.g. coil3) carry an older skiko requirement that Gradle then upgrades to the +# CMP-bundled version. Bump this together with compose-multiplatform. +skiko = "0.144.5" compose-multiplatform-material3 = "1.11.0-alpha06" androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" From e46a8296cb9143ee62555a621a501d276fb88d04 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:45:34 -0500 Subject: [PATCH 052/114] feat(core/ui): add safeLaunch, UiState, KMP permissions, and CMP lifecycle modernization (#5118) --- .skills/code-review/SKILL.md | 6 ++ .../composeResources/values/strings.xml | 4 + core/ui/build.gradle.kts | 1 + .../meshtastic/core/ui/util/PlatformUtils.kt | 69 ++++++++++++++ .../ui/component/TracerouteAlertHandler.kt | 10 +- .../core/ui/qr/ScannedQrCodeViewModel.kt | 9 +- .../meshtastic/core/ui/util/PlatformUtils.kt | 18 ++++ .../core/ui/viewmodel/ViewModelExtensions.kt | 95 ++++++++++++++++++- .../org/meshtastic/core/ui/util/NoopStubs.kt | 9 ++ .../meshtastic/core/ui/util/PlatformUtils.kt | 16 ++++ .../feature/connections/ScannerViewModel.kt | 12 +-- .../feature/map/BaseMapViewModel.kt | 8 +- .../feature/messaging/MessageListPaged.kt | 23 +---- .../feature/messaging/MessageViewModel.kt | 18 ++-- .../feature/messaging/QuickChatViewModel.kt | 9 +- .../messaging/ui/contact/ContactsViewModel.kt | 13 ++- .../feature/node/compass/CompassViewModel.kt | 19 ++-- .../feature/node/metrics/MetricsViewModel.kt | 15 +-- .../feature/settings/SettingsScreen.kt | 2 - ...xternalNotificationConfigScreen.android.kt | 16 +++- .../feature/settings/SettingsViewModel.kt | 11 ++- .../settings/channel/ChannelViewModel.kt | 9 +- .../settings/component/PrivacySection.kt | 52 +++------- .../settings/debugging/DebugViewModel.kt | 15 +-- .../radio/CleanNodeDatabaseViewModel.kt | 9 +- .../settings/radio/RadioConfigViewModel.kt | 90 ++++++++---------- 26 files changed, 374 insertions(+), 184 deletions(-) rename feature/settings/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt (64%) diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md index b39e2d0d9..6a774297c 100644 --- a/.skills/code-review/SKILL.md +++ b/.skills/code-review/SKILL.md @@ -55,3 +55,9 @@ When reviewing code, meticulously verify the following categories. Flag any devi ### 8. ProGuard / R8 Rules - [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned. - [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed. + +## Review Output Guidelines +1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern. +2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio"). +3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous. +4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions. diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 5d7eba25a..4a5e40ade 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -327,6 +327,7 @@ Delivery confirmed Your device may disconnect and reboot while settings are applied. Error + Unknown error Ignore Remove from ignored Add '%1$s' to ignore list? @@ -606,6 +607,9 @@ Output duration (milliseconds) Nag timeout (seconds) Ringtone + Imported ringtone + File is empty + Error importing: %1$s Play Use I2S as buzzer LoRa diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 99221edf1..d07a5afc3 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -60,6 +60,7 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive.navigation3) implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) implementation(libs.jetbrains.lifecycle.viewmodel.compose) + implementation(libs.jetbrains.lifecycle.runtime.compose) } val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } } diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 559169139..bebed2f46 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -27,15 +27,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString +import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.MeshtasticUri import java.net.URLEncoder @@ -216,3 +221,67 @@ actual fun rememberOpenLocationSettings(): () -> Unit { } return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } } } + +@Composable +actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + // On pre-Android 12, BLE scanning is gated by location permission, not Bluetooth. + return remember { { onGranted() } } + } + val currentOnGranted = rememberUpdatedState(onGranted) + val currentOnDenied = rememberUpdatedState(onDenied) + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.values.all { it }) currentOnGranted.value() else currentOnDenied.value() + } + return remember(launcher) { + { + launcher.launch( + arrayOf(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT), + ) + } + } +} + +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + // Pre-Android 13, no runtime notification permission required. + return remember { { onGranted() } } + } + val currentOnGranted = rememberUpdatedState(onGranted) + val currentOnDenied = rememberUpdatedState(onDenied) + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) currentOnGranted.value() else currentOnDenied.value() + } + return remember(launcher) { { launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } } +} + +@Composable +actual fun isLocationPermissionGranted(): Boolean { + val context = LocalContext.current + return rememberOnResumeState { + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_FINE_LOCATION, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } +} + +@Composable +actual fun isGpsDisabled(): Boolean { + val context = LocalContext.current + return rememberOnResumeState { context.gpsDisabled() } +} + +/** + * Remembers a boolean state that is re-evaluated on each [Lifecycle.Event.ON_RESUME], ensuring the value stays fresh + * when the user returns from a permission dialog or system settings screen. + */ +@Composable +private fun rememberOnResumeState(check: () -> Boolean): Boolean { + val state = remember { mutableStateOf(check()) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { state.value = check() } + return state.value +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt index 815f9beb7..a0b87ca6a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kermit.Logger import kotlinx.coroutines.launch import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.okay @@ -89,7 +90,14 @@ fun TracerouteAlertHandler( uiViewModel.clearTracerouteResponse() // Post the error alert after the current alert is dismissed to avoid // the wrapping dismissAlert() in AlertManager immediately clearing it. - scope.launch { uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) } + @Suppress("TooGenericExceptionCaught") + scope.launch { + try { + uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) + } catch (e: Exception) { + Logger.e(e) { "[TracerouteAlertHandler] Failed to show error alert" } + } + } } }, dismissTextRes = Res.string.okay, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index 2c10206aa..db23f1d77 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -17,12 +17,11 @@ package org.meshtastic.core.ui.qr import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -40,7 +39,7 @@ class ScannedQrCodeViewModel( private val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -51,11 +50,11 @@ class ScannedQrCodeViewModel( } private fun setChannel(channel: Channel) { - viewModelScope.launch { radioController.setLocalChannel(channel) } + safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) private fun setConfig(config: Config) { - viewModelScope.launch { radioController.setLocalConfig(config) } + safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index d5910168b..38e870314 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -64,3 +64,21 @@ expect fun rememberSaveFileLauncher( /** Returns a launcher to open the platform's location settings. */ @Composable expect fun rememberOpenLocationSettings(): () -> Unit + +/** Returns a launcher to request Bluetooth scan + connect permissions. No-op on platforms without runtime BLE perms. */ +@Composable expect fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit + +/** Returns a launcher to request the POST_NOTIFICATIONS permission. No-op on platforms that don't require it. */ +@Composable +expect fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit + +/** + * Returns whether location permissions are currently granted. Always `true` on platforms without runtime permissions. + */ +@Composable expect fun isLocationPermissionGranted(): Boolean + +/** + * Returns whether GPS/location services are currently disabled at the system level. Always `false` on platforms where + * this concept doesn't apply. + */ +@Composable expect fun isGpsDisabled(): Boolean diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt index 2201d70bd..b85e68888 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt @@ -14,16 +14,30 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon") +@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon", "TooGenericExceptionCaught") package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.UiText +import org.meshtastic.core.resources.unknown_error +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -40,3 +54,82 @@ fun Flow.stateInWhileSubscribed(initialValue: T, stopTimeout: Duration = started = SharingStarted.WhileSubscribed(stopTimeoutMillis = stopTimeout.inWholeMilliseconds), initialValue = initialValue, ) + +// --------------------------------------------------------------------------- +// UiState: shared Loading / Content / Error wrapper +// --------------------------------------------------------------------------- + +/** + * Lightweight tri-state wrapper for UI data. Prefer this over bare nullable initial values when the UI needs to + * distinguish "still loading" from "genuinely empty." + */ +sealed interface UiState { + /** Data has not yet arrived. */ + data object Loading : UiState + + /** Data is available. */ + data class Content(val data: T) : UiState + + /** An error occurred while loading. */ + data class Error(val message: UiText) : UiState +} + +/** Returns the [Content] data, or `null` if this state is [Loading] or [Error]. */ +fun UiState.dataOrNull(): T? = (this as? UiState.Content)?.data + +/** + * Wraps this [Flow] into a `StateFlow>`, emitting [UiState.Loading] until the first value, then + * [UiState.Content] for each emission. Upstream errors are caught and mapped to [UiState.Error]. + */ +context(viewModel: ViewModel) +fun Flow.asUiState(stopTimeout: Duration = 5.seconds): StateFlow> = + this.map> { UiState.Content(it) } + .onStart { emit(UiState.Loading) } + .catch { e -> + val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error) + emit(UiState.Error(message)) + } + .stateInWhileSubscribed(initialValue = UiState.Loading, stopTimeout = stopTimeout) + +// --------------------------------------------------------------------------- +// safeLaunch: CancellationException-safe coroutine launcher with error routing +// --------------------------------------------------------------------------- + +/** + * Launches a coroutine in [viewModelScope] that catches all exceptions except [CancellationException]. Non-cancellation + * errors are logged and emitted to [errorEvents] (if provided) for one-shot UI consumption (e.g. snackbar / toast). + * + * @param context optional [CoroutineContext] element (typically a dispatcher) merged into the launch. Defaults to + * [EmptyCoroutineContext], inheriting [viewModelScope]'s dispatcher. + * + * ``` + * // In a ViewModel: + * safeLaunch(errorEvents = _errors) { + * repository.saveData(data) + * } + * ``` + */ +context(viewModel: ViewModel) +fun safeLaunch( + context: CoroutineContext = EmptyCoroutineContext, + errorEvents: MutableSharedFlow? = null, + tag: String? = null, + block: suspend CoroutineScope.() -> Unit, +): Job = viewModel.viewModelScope.launch(context) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val label = tag ?: "safeLaunch" + Logger.e(e) { "[$label] Unhandled exception" } + val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error) + errorEvents?.tryEmit(message) + } +} + +/** + * Creates and returns a [MutableSharedFlow] intended for one-shot error events. Expose as `SharedFlow` via + * [asSharedFlow] in the ViewModel, and collect in the UI to show snackbars or toasts. + */ +fun errorEventFlow(): MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index 590bd1fe9..0621463bd 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -57,4 +57,13 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT @Composable actual fun rememberOpenLocationSettings(): () -> Unit = {} +@Composable actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {} + +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {} + +@Composable actual fun isLocationPermissionGranted(): Boolean = true + +@Composable actual fun isGpsDisabled(): Boolean = false + @Composable actual fun SetScreenBrightness(brightness: Float) {} diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index aa3435d29..08c414490 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -130,3 +130,19 @@ actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () @Composable actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } } + +/** JVM no-op — Desktop does not require runtime Bluetooth permissions. */ +@Composable +actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { onGranted() } + +/** JVM no-op — Desktop does not require runtime notification permissions. */ +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { + onGranted() +} + +/** JVM — location permission is always considered granted on Desktop. */ +@Composable actual fun isLocationPermissionGranted(): Boolean = true + +/** JVM — GPS is never disabled on Desktop (concept doesn't apply). */ +@Composable actual fun isGpsDisabled(): Boolean = false diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index ccdc9ea24..7e57f2eff 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.model.RadioController @@ -37,6 +36,7 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase @@ -76,7 +76,7 @@ open class ScannerViewModel( scannedBleDevices.value = emptyMap() scanJob = - viewModelScope.launch { + safeLaunch(tag = "startBleScan") { try { bleScanner .scan( @@ -89,8 +89,6 @@ open class ScannerViewModel( scannedBleDevices.update { current -> current + (device.address to device) } } } - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - co.touchlab.kermit.Logger.w(e) { "BLE scan failed" } } finally { isBleScanningState.value = false } @@ -185,11 +183,11 @@ open class ScannerViewModel( fun addRecentAddress(address: String, name: String) { if (!address.startsWith("t")) return - viewModelScope.launch { recentAddressesDataSource.add(RecentAddress(address, name)) } + safeLaunch(tag = "addRecentAddress") { recentAddressesDataSource.add(RecentAddress(address, name)) } } fun removeRecentAddress(address: String) { - viewModelScope.launch { recentAddressesDataSource.remove(address) } + safeLaunch(tag = "removeRecentAddress") { recentAddressesDataSource.remove(address) } } /** @@ -221,7 +219,7 @@ open class ScannerViewModel( } } is DeviceListEntry.Tcp -> { - viewModelScope.launch { + safeLaunch(tag = "onSelectedTcp") { radioPrefs.setDevName(it.name) addRecentAddress(it.fullAddress, it.name) changeDeviceAddress(it.fullAddress) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index a1a31dbf4..294d84e4c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -17,14 +17,12 @@ package org.meshtastic.feature.map import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds @@ -41,6 +39,7 @@ import org.meshtastic.core.resources.eight_hours import org.meshtastic.core.resources.one_day import org.meshtastic.core.resources.one_hour import org.meshtastic.core.resources.two_days +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint @@ -147,7 +146,8 @@ open class BaseMapViewModel( fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) - fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) } + fun deleteWaypoint(id: Int) = + safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) } fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) @@ -159,7 +159,7 @@ open class BaseMapViewModel( } private fun sendDataPacket(p: DataPacket) { - viewModelScope.launch(ioDispatcher) { radioController.sendMessage(p) } + safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) } } fun generatePacketId(): Int = radioController.getPacketId() diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index 9cd435f82..9a742a4ea 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -44,8 +43,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType @@ -452,23 +450,12 @@ private fun UpdateUnreadCountPaged( onUnreadChange: (Long, Long) -> Unit, ) { val currentOnUnreadChange by rememberUpdatedState(onUnreadChange) - val lifecycleOwner = LocalLifecycleOwner.current - var isResumed by remember { - mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) - } + var isResumed by remember { mutableStateOf(false) } // Track lifecycle state changes - DisposableEffect(lifecycleOwner) { - val observer = - androidx.lifecycle.LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> isResumed = true - Lifecycle.Event.ON_PAUSE -> isResumed = false - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + LifecycleResumeEffect(Unit) { + isResumed = true + onPauseOrDispose { isResumed = false } } // Track remote message count to restart effect when remote messages change diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 7c57b46af..4d3e5679d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ContactSettings @@ -49,6 +48,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.usecase.SendMessageUseCase +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet @@ -157,7 +157,7 @@ class MessageViewModel( } fun setTitle(title: String) { - viewModelScope.launch { _title.value = title } + _title.value = title } fun getMessagesFromPaged(contactKey: String): Flow> { @@ -190,7 +190,9 @@ class MessageViewModel( } fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") { + packetRepository.setContactFilteringDisabled(contactKey, disabled) + } } fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) @@ -211,21 +213,21 @@ class MessageViewModel( * @param replyId The ID of the message this is a reply to, if any. */ fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) { - viewModelScope.launch { sendMessageUseCase.invoke(str, contactKey, replyId) } + safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) } } - fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch { + fun sendReaction(emoji: String, replyId: Int, contactKey: String) = safeLaunch(tag = "sendReaction") { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) } fun deleteMessages(uuidList: List) = - viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) } + safeLaunch(context = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) } fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) = - viewModelScope.launch(ioDispatcher) { + safeLaunch(context = ioDispatcher, tag = "clearUnreadCount") { val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE if (lastReadTimestamp <= existingTimestamp) { - return@launch + return@safeLaunch } packetRepository.clearUnreadCount(contact, lastReadTimestamp) packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt index 53d023d08..6451b8885 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt @@ -17,12 +17,11 @@ package org.meshtastic.feature.messaging import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.repository.QuickChatActionRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed @KoinViewModel @@ -31,7 +30,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) fun updateActionPositions(actions: List) { - viewModelScope.launch(ioDispatcher) { + safeLaunch(context = ioDispatcher, tag = "updateActionPositions") { for (position in actions.indices) { quickChatActionRepository.setItemPosition(actions[position].uuid, position) } @@ -39,8 +38,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR } fun addQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) } + safeLaunch(context = ioDispatcher, tag = "addQuickChatAction") { quickChatActionRepository.upsert(action) } fun deleteQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) } + safeLaunch(context = ioDispatcher, tag = "deleteQuickChatAction") { quickChatActionRepository.delete(action) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index 865242cfb..f8aa46032 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Contact @@ -37,6 +36,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet import kotlin.collections.map as collectionsMap @@ -188,17 +188,20 @@ class ContactsViewModel( fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) fun deleteContacts(contacts: List) = - viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) } + safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) } - fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() } + fun markAllAsRead() = + safeLaunch(context = ioDispatcher, tag = "markAllAsRead") { packetRepository.clearAllUnreadCounts() } fun setMuteUntil(contacts: List, until: Long) = - viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) } + safeLaunch(context = ioDispatcher, tag = "setMuteUntil") { packetRepository.setMuteUntil(contacts, until) } fun getContactSettings() = packetRepository.getContactSettings() fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") { + packetRepository.setContactFilteringDisabled(contactKey, disabled) + } } /** diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index b7c5f35bd..699021fbc 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.node.compass import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,7 +25,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.formatString @@ -37,6 +35,7 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.ui.component.precisionBitsToMeters +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.proto.Config import org.meshtastic.proto.Position import kotlin.math.abs @@ -92,13 +91,17 @@ class CompassViewModel( updatesJob?.cancel() - updatesJob = viewModelScope.launch { - combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { heading, location -> - buildState(heading, location) + updatesJob = + safeLaunch(tag = "compassUpdates") { + combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { + heading, + location, + -> + buildState(heading, location) + } + .flowOn(dispatchers.default) + .collect { _uiState.value = it } } - .flowOn(dispatchers.default) - .collect { _uiState.value = it } - } } fun stop() { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 3b6ea5656..b7ab25368 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import okio.ByteString.Companion.decodeBase64 @@ -60,6 +59,7 @@ import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.view_on_map import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.toMessageRes +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase @@ -181,7 +181,8 @@ open class MetricsViewModel( fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum) - fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } + fun deleteLog(uuid: String) = + safeLaunch(context = dispatchers.io, tag = "deleteLog") { meshLogRepository.deleteLog(uuid) } fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { val cached = tracerouteOverlayCache.value[requestId] @@ -216,7 +217,7 @@ open class MetricsViewModel( private fun List.numSet(): Set = map { it.num }.toSet() init { - viewModelScope.launch { + safeLaunch(tag = "tracerouteCollector") { serviceRepository.tracerouteResponse.filterNotNull().collect { response -> val overlay = TracerouteOverlay( @@ -232,7 +233,7 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel created" } } - fun clearPosition() = viewModelScope.launch(dispatchers.io) { + fun clearPosition() = safeLaunch(context = dispatchers.io, tag = "clearPosition") { (manualNodeId.value ?: nodeIdFromRoute)?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) } @@ -276,7 +277,7 @@ open class MetricsViewModel( overlay: TracerouteOverlay?, onViewOnMap: (Int, String) -> Unit, ) { - viewModelScope.launch { + safeLaunch(tag = "showTracerouteDetail") { val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first() alertManager.showAlert( titleRes = Res.string.traceroute, @@ -299,7 +300,7 @@ open class MetricsViewModel( if (errorRes != null) { // Post the error alert after the current alert is dismissed to avoid // the wrapping dismissAlert() in AlertManager immediately clearing it. - viewModelScope.launch { + safeLaunch(tag = "tracerouteError") { alertManager.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) } } else { @@ -336,7 +337,7 @@ open class MetricsViewModel( epochSeconds: (T) -> Long, rowMapper: (T) -> String, ) { - viewModelScope.launch(dispatchers.io) { + safeLaunch(context = dispatchers.io, tag = "exportCsv") { fileService.write(uri) { sink -> sink.writeUtf8(header) rows.forEach { item -> diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index c33c3a293..82558309d 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.accompanist.permissions.ExperimentalPermissionsApi import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.toDate @@ -72,7 +71,6 @@ import org.meshtastic.proto.DeviceProfile import java.text.SimpleDateFormat import java.util.Locale -@OptIn(ExperimentalPermissionsApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun SettingsScreen( diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt index fe5e381f6..063add0d1 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt @@ -30,17 +30,26 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.import_label import org.meshtastic.core.resources.play +import org.meshtastic.core.resources.ringtone_file_empty +import org.meshtastic.core.resources.ringtone_import_error +import org.meshtastic.core.resources.ringtone_imported import org.meshtastic.core.ui.icon.FolderOpen import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.PlayArrow import java.io.File private const val MAX_RINGTONE_SIZE = 230 +private const val IMPORT_ERROR_PLACEHOLDER = "@@ERROR@@" @Suppress("TooGenericExceptionCaught") @Composable actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) { val context = LocalContext.current + val importedText = stringResource(Res.string.ringtone_imported) + val emptyText = stringResource(Res.string.ringtone_file_empty) + // Pre-resolve the format pattern for use in the non-composable launcher callback. + // Using a sentinel placeholder that will be replaced at call-site. + val importErrorPrefix = stringResource(Res.string.ringtone_import_error, IMPORT_ERROR_PLACEHOLDER) val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> @@ -52,15 +61,16 @@ actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (Stri val read = reader.read(buffer) if (read > 0) { onRingtoneImported(String(buffer, 0, read)) - Toast.makeText(context, "Imported ringtone", Toast.LENGTH_SHORT).show() + Toast.makeText(context, importedText, Toast.LENGTH_SHORT).show() } else { - Toast.makeText(context, "File is empty", Toast.LENGTH_SHORT).show() + Toast.makeText(context, emptyText, Toast.LENGTH_SHORT).show() } } } } catch (e: Exception) { Logger.e(e) { "Error importing ringtone" } - Toast.makeText(context, "Error importing: ${e.message}", Toast.LENGTH_SHORT).show() + val errorMsg = importErrorPrefix.replace(IMPORT_ERROR_PLACEHOLDER, e.message ?: e.toString()) + Toast.makeText(context, errorMsg, Toast.LENGTH_SHORT).show() } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 27c57fafe..fc5923c1a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -17,7 +17,6 @@ package org.meshtastic.feature.settings import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,7 +24,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import okio.BufferedSink import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.BuildConfigProvider @@ -51,6 +49,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.LocalConfig @@ -146,12 +145,12 @@ class SettingsViewModel( val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow() fun setMeshLogRetentionDays(days: Int) { - viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) } + safeLaunch(tag = "setMeshLogRetentionDays") { setMeshLogSettingsUseCase.setRetentionDays(days) } _meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) } fun setMeshLogLoggingEnabled(enabled: Boolean) { - viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } + safeLaunch(tag = "setMeshLogLoggingEnabled") { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } _meshLogLoggingEnabled.value = enabled } @@ -183,7 +182,9 @@ class SettingsViewModel( * @param filterPortnum If provided, only packets with this port number will be exported. */ fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) { - viewModelScope.launch { fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } } + safeLaunch(tag = "saveDataCsv") { + fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } + } } private suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index f479e3d26..c1d36e2ee 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -17,11 +17,9 @@ package org.meshtastic.feature.settings.channel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.RadioController @@ -30,6 +28,7 @@ import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -86,7 +85,7 @@ class ChannelViewModel( } /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -97,12 +96,12 @@ class ChannelViewModel( } fun setChannel(channel: Channel) { - viewModelScope.launch { radioController.setLocalChannel(channel) } + safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { - viewModelScope.launch { radioController.setLocalConfig(config) } + safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } } fun trackShare() { diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt similarity index 64% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt index d7910f2ea..3930580d1 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt @@ -16,15 +16,9 @@ */ package org.meshtastic.feature.settings.component -import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.analytics_okay import org.meshtastic.core.resources.app_settings @@ -34,11 +28,12 @@ import org.meshtastic.core.ui.component.SwitchListItem import org.meshtastic.core.ui.icon.BugReport import org.meshtastic.core.ui.icon.LocationOn import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.core.ui.util.showToast +import org.meshtastic.core.ui.util.isGpsDisabled +import org.meshtastic.core.ui.util.isLocationPermissionGranted +import org.meshtastic.core.ui.util.rememberRequestLocationPermission +import org.meshtastic.core.ui.util.rememberShowToastResource /** Section managing privacy settings like analytics and location sharing. */ -@OptIn(ExperimentalPermissionsApi::class) @Composable fun PrivacySection( analyticsAvailable: Boolean, @@ -51,21 +46,22 @@ fun PrivacySection( startProvideLocation: () -> Unit, stopProvideLocation: () -> Unit, ) { - val context = LocalContext.current - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - val isGpsDisabled = context.gpsDisabled() + val showToast = rememberShowToastResource() + val isLocationGranted = isLocationPermissionGranted() + val isGpsOff = isGpsDisabled() + val requestLocationPermission = + rememberRequestLocationPermission(onGranted = { startProvideLocation() }, onDenied = {}) - LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) { + LaunchedEffect(provideLocation, isLocationGranted, isGpsOff) { if (provideLocation) { - if (locationPermissionsState.allPermissionsGranted) { - if (!isGpsDisabled) { + if (isLocationGranted) { + if (!isGpsOff) { startProvideLocation() } else { - context.showToast(Res.string.location_disabled) + showToast(Res.string.location_disabled) } } else { - locationPermissionsState.launchMultiplePermissionRequest() + requestLocationPermission() } } else { stopProvideLocation() @@ -85,7 +81,7 @@ fun PrivacySection( SwitchListItem( text = stringResource(Res.string.provide_location_to_mesh), leadingIcon = MeshtasticIcons.LocationOn, - enabled = !isGpsDisabled, + enabled = !isGpsOff, checked = provideLocation, onClick = { onToggleLocation(!provideLocation) }, ) @@ -93,21 +89,3 @@ fun PrivacySection( HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph) } } - -@Preview(showBackground = true) -@Composable -private fun PrivacySectionPreview() { - AppTheme { - PrivacySection( - analyticsAvailable = true, - analyticsEnabled = true, - onToggleAnalytics = {}, - provideLocation = true, - onToggleLocation = {}, - homoglyphEnabled = false, - onToggleHomoglyph = {}, - startProvideLocation = {}, - stopProvideLocation = {}, - ) - } -} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 8ed442ccd..682e0e8c3 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.DateFormatter @@ -47,6 +45,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_clear import org.meshtastic.core.resources.debug_clear_logs_confirm import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket @@ -265,16 +264,18 @@ class DebugViewModel( val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) meshLogPrefs.setRetentionDays(clamped) _retentionDays.value = clamped - viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) } + safeLaunch(tag = "setRetentionDays") { meshLogRepository.deleteLogsOlderThan(clamped) } } fun setLoggingEnabled(enabled: Boolean) { meshLogPrefs.setLoggingEnabled(enabled) _loggingEnabled.value = enabled if (!enabled) { - viewModelScope.launch { meshLogRepository.deleteAll() } + safeLaunch(tag = "disableLogging") { meshLogRepository.deleteAll() } } else { - viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) } + safeLaunch(tag = "enableLogging") { + meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) + } } } @@ -286,7 +287,7 @@ class DebugViewModel( init { Logger.d { "DebugViewModel created" } - viewModelScope.launch { + safeLaunch(tag = "searchMatchUpdater") { combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs -> searchManager.findSearchMatches(searchText, logs) } @@ -406,7 +407,7 @@ class DebugViewModel( ) } - fun deleteAllLogs() = viewModelScope.launch(ioDispatcher) { meshLogRepository.deleteAll() } + fun deleteAllLogs() = safeLaunch(context = ioDispatcher, tag = "deleteAllLogs") { meshLogRepository.deleteAll() } @Immutable data class UiMeshLog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index d47791300..26bacd139 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -17,10 +17,8 @@ package org.meshtastic.feature.settings.radio import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.nowSeconds @@ -31,6 +29,7 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.clean_node_database_confirmation import org.meshtastic.core.resources.clean_now import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.viewmodel.safeLaunch private const val MIN_DAYS_THRESHOLD = 7f @@ -65,7 +64,7 @@ class CleanNodeDatabaseViewModel( /** Updates the list of nodes to be deleted based on the current filter criteria. */ fun getNodesToDelete() { - viewModelScope.launch { + safeLaunch(tag = "getNodesToDelete") { _nodesToDelete.value = cleanNodeDatabaseUseCase.getNodesToClean( olderThanDays = _olderThanDays.value, @@ -76,7 +75,7 @@ class CleanNodeDatabaseViewModel( } fun requestCleanNodes() { - viewModelScope.launch { + safeLaunch(tag = "requestCleanNodes") { val count = _nodesToDelete.value.size val message = getString(Res.string.clean_node_database_confirmation, count) alertManager.showAlert( @@ -93,7 +92,7 @@ class CleanNodeDatabaseViewModel( * them. */ fun cleanNodes() { - viewModelScope.launch { + safeLaunch(tag = "cleanNodes") { val nodeNums = _nodesToDelete.value.map { it.num } cleanNodeDatabaseUseCase.cleanNodes(nodeNums) // Clear the list after deletion or if it was empty diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 592c15d3a..4b8427c87 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel @@ -62,6 +61,7 @@ import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.cant_shutdown import org.meshtastic.core.resources.timeout import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.proto.AdminMessage @@ -155,7 +155,7 @@ open class RadioConfigViewModel( val radioConfigState: StateFlow = _radioConfigState fun setPreserveFavorites(preserveFavorites: Boolean) { - viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } + _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } private val _currentDeviceProfile = MutableStateFlow(DeviceProfile()) @@ -242,7 +242,7 @@ open class RadioConfigViewModel( fun setOwner(user: User) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setOwner") { _radioConfigState.update { it.copy(userConfig = user) } val packetId = radioConfigUseCase.setOwner(destNum, user) registerRequestId(packetId) @@ -252,14 +252,14 @@ open class RadioConfigViewModel( fun updateChannels(new: List, old: List) { val destNum = destNode.value?.num ?: return getChannelList(new, old).forEach { channel -> - viewModelScope.launch { + safeLaunch(tag = "setRemoteChannel") { val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel) registerRequestId(packetId) } } if (destNum == myNodeNum) { - viewModelScope.launch { + safeLaunch(tag = "migrateChannels") { packetRepository.migrateChannelsByPSK(old, new) radioConfigRepository.replaceAllSettings(new) } @@ -269,7 +269,7 @@ open class RadioConfigViewModel( fun setConfig(config: Config) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setConfig") { _radioConfigState.update { state -> state.copy( radioConfig = @@ -293,7 +293,7 @@ open class RadioConfigViewModel( @Suppress("CyclomaticComplexMethod") fun setModuleConfig(config: ModuleConfig) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setModuleConfig") { _radioConfigState.update { state -> state.copy( moduleConfig = @@ -326,13 +326,13 @@ open class RadioConfigViewModel( fun setRingtone(ringtone: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(ringtone = ringtone) } - viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) } + safeLaunch(tag = "setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) } } fun setCannedMessages(messages: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(cannedMessageMessages = messages) } - viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) } + safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) } } private fun sendAdminRequest(destNum: Int) { @@ -343,7 +343,7 @@ open class RadioConfigViewModel( when (route) { AdminRoute.REBOOT.name -> - viewModelScope.launch { + safeLaunch(tag = "reboot") { val packetId = adminActionsUseCase.reboot(destNum) registerRequestId(packetId) } @@ -352,7 +352,7 @@ open class RadioConfigViewModel( if (metadata?.canShutdown != true) { sendError(Res.string.cant_shutdown) } else { - viewModelScope.launch { + safeLaunch(tag = "shutdown") { val packetId = adminActionsUseCase.shutdown(destNum) registerRequestId(packetId) } @@ -360,13 +360,13 @@ open class RadioConfigViewModel( } AdminRoute.FACTORY_RESET.name -> - viewModelScope.launch { + safeLaunch(tag = "factoryReset") { val isLocal = (destNum == myNodeNum) val packetId = adminActionsUseCase.factoryReset(destNum, isLocal) registerRequestId(packetId) } AdminRoute.NODEDB_RESET.name -> - viewModelScope.launch { + safeLaunch(tag = "nodedbReset") { val isLocal = (destNum == myNodeNum) val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal) registerRequestId(packetId) @@ -376,55 +376,43 @@ open class RadioConfigViewModel( fun setFixedPosition(position: Position) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) } + safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) } } fun removeFixedPosition() { val destNum = destNode.value?.num ?: return - viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) } + safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) } } fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) { - viewModelScope.launch { - try { - var profile: DeviceProfile? = null - fileService.read(uri) { source -> - importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } - } - profile?.let { onResult(it) } - } catch (ex: Exception) { - Logger.e { "Import DeviceProfile error: ${ex.message}" } + safeLaunch(tag = "importProfile") { + var profile: DeviceProfile? = null + fileService.read(uri) { source -> + importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } } + profile?.let { onResult(it) } } } fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) { - viewModelScope.launch { - try { - fileService.write(uri) { sink -> - exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } - } - } catch (ex: Exception) { - Logger.e { "Can't write file error: ${ex.message}" } + safeLaunch(tag = "exportProfile") { + fileService.write(uri) { sink -> + exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } } } } fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) { - viewModelScope.launch { - try { - fileService.write(uri) { sink -> - exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } - } - } catch (ex: Exception) { - Logger.e { "Can't write security keys JSON error: ${ex.message}" } + safeLaunch(tag = "exportSecurityConfig") { + fileService.write(uri) { sink -> + exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } } } } fun installProfile(protobuf: DeviceProfile) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) } + safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) } } fun clearPacketResponse() { @@ -439,17 +427,17 @@ open class RadioConfigViewModel( when (route) { ConfigRoute.USER -> - viewModelScope.launch { + safeLaunch(tag = "getOwner") { val packetId = radioConfigUseCase.getOwner(destNum) registerRequestId(packetId) } ConfigRoute.CHANNELS -> { - viewModelScope.launch { + safeLaunch(tag = "getChannel0") { val packetId = radioConfigUseCase.getChannel(destNum, 0) registerRequestId(packetId) } - viewModelScope.launch { + safeLaunch(tag = "getLoraConfig") { val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value) registerRequestId(packetId) } @@ -458,7 +446,7 @@ open class RadioConfigViewModel( } is AdminRoute -> { - viewModelScope.launch { + safeLaunch(tag = "getSessionKeyConfig") { val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) registerRequestId(packetId) @@ -468,18 +456,18 @@ open class RadioConfigViewModel( is ConfigRoute -> { if (route == ConfigRoute.LORA) { - viewModelScope.launch { + safeLaunch(tag = "getChannel0ForLora") { val packetId = radioConfigUseCase.getChannel(destNum, 0) registerRequestId(packetId) } } if (route == ConfigRoute.NETWORK) { - viewModelScope.launch { + safeLaunch(tag = "getConnectionStatus") { val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum) registerRequestId(packetId) } } - viewModelScope.launch { + safeLaunch(tag = "getConfig") { val packetId = radioConfigUseCase.getConfig(destNum, route.type) registerRequestId(packetId) } @@ -487,18 +475,18 @@ open class RadioConfigViewModel( is ModuleRoute -> { if (route == ModuleRoute.CANNED_MESSAGE) { - viewModelScope.launch { + safeLaunch(tag = "getCannedMessages") { val packetId = radioConfigUseCase.getCannedMessages(destNum) registerRequestId(packetId) } } if (route == ModuleRoute.EXT_NOTIFICATION) { - viewModelScope.launch { + safeLaunch(tag = "getRingtone") { val packetId = radioConfigUseCase.getRingtone(destNum) registerRequestId(packetId) } } - viewModelScope.launch { + safeLaunch(tag = "getModuleConfig") { val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type) registerRequestId(packetId) } @@ -568,7 +556,7 @@ open class RadioConfigViewModel( } val requestTimeout = 30.seconds - viewModelScope.launch { + safeLaunch(tag = "requestTimeout") { delay(requestTimeout) if (requestIds.value.contains(packetId)) { requestIds.update { it.apply { remove(packetId) } } @@ -628,7 +616,7 @@ open class RadioConfigViewModel( val index = response.index if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { // Not done yet, request next channel - viewModelScope.launch { + safeLaunch(tag = "getNextChannel") { val packetId = radioConfigUseCase.getChannel(destNum, index + 1) registerRequestId(packetId) } From 743851b0b55dbcd9653a009d33885066e219dcc5 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:35:19 -0500 Subject: [PATCH 053/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5120) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../src/commonMain/composeResources/values-bg/strings.xml | 1 + .../src/commonMain/composeResources/values-cs/strings.xml | 1 + .../src/commonMain/composeResources/values-de/strings.xml | 1 + .../src/commonMain/composeResources/values-et/strings.xml | 1 + .../src/commonMain/composeResources/values-fi/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-fr/strings.xml | 1 + .../src/commonMain/composeResources/values-it/strings.xml | 1 + .../src/commonMain/composeResources/values-pl/strings.xml | 1 + .../src/commonMain/composeResources/values-ro/strings.xml | 1 + .../src/commonMain/composeResources/values-ru/strings.xml | 1 + .../src/commonMain/composeResources/values-sv/strings.xml | 1 + .../src/commonMain/composeResources/values-uk/strings.xml | 1 + .../src/commonMain/composeResources/values-zh-rCN/strings.xml | 1 + .../src/commonMain/composeResources/values-zh-rTW/strings.xml | 1 + 14 files changed, 17 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index ff2ceced6..56f32b1ba 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -250,6 +250,7 @@ Съобщението е доставено Устройството ви може да прекъсне връзката и да се рестартира, докато се прилагат настройките. Грешка + Неизвестна грешка Игнорирай Премахване от игнорирани Добави '%1$s' към списъка с игнорирани? diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index 51e156e5d..868a84993 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -263,6 +263,7 @@ Doručeno Vaše zařízení se může odpojit a restartovat při aplikaci nastavení. Chyba + Neznámá chyba Ignorovat Odstranit z ignorovaných Přidat '%1$s' do seznamu ignorovaných? diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index a358cb984..01c1aaa2a 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -302,6 +302,7 @@ Zustellung bestätigt Ihr Gerät kann die Verbindung trennen und neu starten, während die Einstellungen angewendet werden. Fehler + Unbekannter Fehler Ignorieren Aus Ignorierliste entfernen '%1$s' zur Ignorieren-Liste hinzufügen? diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 969d46acb..4b8e5a879 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -302,6 +302,7 @@ Kohale toimetatud Seadete rakendamise ajal võib seadme ühendus katkeda ja taaskäivituda. Viga + Tundmatu viga Eira Eemalda ignoreeritute hulgast Lisa '%1$s' eiramis loendisse? diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index 98a2fc84c..504b821b2 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -302,6 +302,7 @@ Toimitus vahvistettu Laitteesi saattaa katkaista yhteyden ja käynnistyä uudelleen, kun asetuksia otetaan käyttöön. Virhe + Tuntematon virhe Jätä huomiotta Poista huomioimattomista Lisää '%1$s' jätä huomiotta listalle? Laite käynnistyy uudelleen muutoksen tekemisen jälkeen. @@ -581,6 +582,9 @@ Ulostulon kesto (millisekuntia) Hälytysaikakatkaisu (sekuntia) Soittoääni + Tuotu soittoääni + Tiedosto on tyhjä + Virhe tuotaessa: %1$s Aloita Käytä I2S protokollaa äänimerkille LoRa diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index fe1a9aaef..16da56ad7 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -276,6 +276,7 @@ Reconfiguration de NodeDB Réception confirmée par le destinataire Erreur + Une erreur inconnue s'est produite Ignorer Supprimer des ignorés Ajouter '%1$s' à la liste des ignorés ? Votre radio va redémarrer après avoir effectué ce changement. diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index 8e9066c22..406626027 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -284,6 +284,7 @@ Consegna confermata Il dispositivo potrebbe disconnettersi e riavviarsi durante l'applicazione delle impostazioni. Errore + Errore sconosciuto Ignora Rimuovi da ignorati Aggiungere '%1$s' alla lista degli ignorati? diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 448e7eaac..64f32551d 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -268,6 +268,7 @@ Zresetuj NodeDB Dostarczono Błąd + Nieznany błąd Zignoruj Usuń z listy ignorowanych Dodać '%1$s' do listy ignorowanych? diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index 440302ec3..ff5de3636 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -302,6 +302,7 @@ Livrare confirmată Dispozitivul dumneavoastră se poate deconecta şi reporni în timp ce setările sunt aplicate. Eroare + Eroare necunoscuta Ignoră Eliminați din lista ignorate Adaugă '%1$s' in lista de ignor? Radioul tău va reporni după ce această modificare. diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index b414c046c..a201c1dc8 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -308,6 +308,7 @@ Доставка подтверждена Ваше устройство может отключиться и перезагрузиться во время применения настроек. Ошибка + Неизвестная ошибка Игнорировать Удалить из игнорируемых Добавить '%1$s' в список игнорируемых? diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index da0bb8d4f..fce685c0a 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -272,6 +272,7 @@ Nollställ NodeDB Sändning bekräftad Fel + Okänt fel Ignorera Ta bort från ignorerade Lägg till '%1$s' på ignorera-listan? Din radioenhet kommer att starta om efter denna ändring. diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index 2c885d5e5..e92552e55 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -222,6 +222,7 @@ Очищення бази вузлів Доставку підтверджено Помилка + Невідома помилка Ігнорувати Вилучити з ігнорованих Додати '%1$s' до чорного списку? Після цієї зміни ваш пристрій перезавантажиться. diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml index f7c3d5e92..bfb4e6fc0 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -289,6 +289,7 @@ 已送达 在应用设置时,您的设备可能会断开连接并重启。 错误 + 未知错误 忽略 从忽略中删除 添加 '%1$s' 到忽略列表? diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml index fb6856a0e..b4d05cfdb 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -273,6 +273,7 @@ 已確認送達 在設定套用的過程中,您的裝置可能會斷開連線並重新啟動。 錯誤 + 未知錯誤 忽略 從忽略清單中移除 將 '%1$s' 加入忽略清單嗎? From 3c7e1266f819f90df2bfca6717cd1df0414d6c3a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:01:03 -0500 Subject: [PATCH 054/114] fix: truncate traceroute chart x-values to whole seconds to prevent Vico crash (#5122) --- .../feature/node/metrics/TracerouteChart.kt | 2 +- .../feature/node/metrics/TracerouteChartTest.kt | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt index ce6300205..c1e5e69fe 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt @@ -112,7 +112,7 @@ internal fun resolveTraceroutePoints(requests: List, results: List() + + val point = resolveTraceroutePoints(requests, results).first() + + // Must truncate to whole seconds to avoid Vico "x-values are too precise" crash + assertEquals(1000.0, point.timeSeconds) + } + @Test fun returnHops_computedWhenRouteBackAvailable() { val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) From 99378c92919a4936afdbe1a19fea3997bb1f4af5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:50:59 -0500 Subject: [PATCH 055/114] chore(deps): update core/proto/src/main/proto digest to 98e95ee (#5123) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- core/proto/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index a4c649bd3..98e95eeaa 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit a4c649bd3e877dab9011d9e32dc778640ec22852 +Subproject commit 98e95eeaa26770e6ede0291753623e4744b6ede1 From 9acdf5309f8f0ab96b30d6505bdac5e93a3bb72c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:41:01 -0500 Subject: [PATCH 056/114] =?UTF-8?q?refactor:=20modern=20APIs=20=E2=80=94?= =?UTF-8?q?=20Koin=204.2,=20CMP=201.11,=20Ktor=20resilience,=20Room=20@Ups?= =?UTF-8?q?ert,=20injected=20dispatchers=20(#5119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 - .../org/meshtastic/app/map/MapViewModel.kt | 72 +++-- .../app/map/prefs/di/GoogleMapsKoinModule.kt | 13 +- .../kotlin/org/meshtastic/app/MainActivity.kt | 2 +- .../org/meshtastic/app/di/NetworkModule.kt | 12 + .../core/database/dao/DeviceHardwareDao.kt | 9 +- .../core/database/dao/FirmwareReleaseDao.kt | 6 +- .../core/database/dao/NodeInfoDao.kt | 8 +- .../database/dao/TracerouteNodePositionDao.kt | 6 +- .../di/CoreDatastoreAndroidModule.kt | 10 +- .../core/datastore/di/CoreDatastoreModule.kt | 9 +- .../core/network/HttpClientDefaults.kt | 31 ++ .../core/service/MarkAsReadReceiver.kt | 6 +- .../meshtastic/core/service/MeshService.kt | 6 +- .../core/service/ReactionReceiver.kt | 6 +- .../meshtastic/core/service/ReplyReceiver.kt | 6 +- core/ui/build.gradle.kts | 1 - .../ui/component/TimeTickWithLifecycle.kt | 5 +- .../ui/component/TimeTickWithLifecycle.kt | 5 +- .../desktop/DesktopNotificationManager.kt | 24 +- .../kotlin/org/meshtastic/desktop/Main.kt | 297 +++++++++++------- .../data/DesktopPreferencesDataSource.kt | 28 +- .../meshtastic/desktop/di/DesktopDiModule.kt | 4 + .../desktop/di/DesktopKoinModule.kt | 16 +- .../desktop/di/DesktopPlatformModule.kt | 70 +++-- .../desktop/navigation/DesktopNavigation.kt | 29 +- .../DesktopMeshServiceNotifications.kt | 33 +- .../desktop/radio/DesktopMessageQueue.kt | 5 +- .../meshtastic/desktop/stub/CompassStubs.kt | 3 + .../desktop/ui/DesktopMainScreen.kt | 5 +- feature/messaging/build.gradle.kts | 1 - gradle/libs.versions.toml | 2 - 32 files changed, 453 insertions(+), 278 deletions(-) create mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0942756c0..39e6bbcc7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -266,7 +266,6 @@ dependencies { implementation(libs.usb.serial.android) implementation(libs.androidx.work.runtime.ktx) implementation(libs.koin.android) - implementation(libs.koin.androidx.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.androidx.workmanager) implementation(libs.koin.annotations) diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 70ff4858d..e4eabbb76 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -28,7 +28,11 @@ import com.google.android.gms.maps.model.TileProvider import com.google.android.gms.maps.model.UrlTileProvider import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.MapType -import kotlinx.coroutines.Dispatchers +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.isSuccess +import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -45,6 +49,7 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.app.map.model.CustomTileProviderConfig import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs import org.meshtastic.app.map.repository.CustomTileProviderRepository +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.NodeRepository @@ -77,6 +82,8 @@ data class MapCameraPosition( @KoinViewModel class MapViewModel( private val application: Application, + private val dispatchers: CoroutineDispatchers, + private val httpClient: HttpClient, mapPrefs: MapPrefs, private val googleMapsPrefs: GoogleMapsPrefs, nodeRepository: NodeRepository, @@ -404,7 +411,7 @@ class MapViewModel( } private fun loadPersistedLayers() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(dispatchers.io) { try { val layersDir = File(application.filesDir, "map_layers") if (layersDir.exists() && layersDir.isDirectory) { @@ -412,32 +419,33 @@ class MapViewModel( if (persistedLayerFiles != null) { val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value - val loadedItems = persistedLayerFiles.mapNotNull { file -> - if (file.isFile) { - val layerType = - when (file.extension.lowercase()) { - "kml", - "kmz", - -> LayerType.KML - "geojson", - "json", - -> LayerType.GEOJSON - else -> null - } + val loadedItems = + persistedLayerFiles.mapNotNull { file -> + if (file.isFile) { + val layerType = + when (file.extension.lowercase()) { + "kml", + "kmz", + -> LayerType.KML + "geojson", + "json", + -> LayerType.GEOJSON + else -> null + } - layerType?.let { - val uri = Uri.fromFile(file) - MapLayerItem( - name = file.nameWithoutExtension, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = it, - ) + layerType?.let { + val uri = Uri.fromFile(file) + MapLayerItem( + name = file.nameWithoutExtension, + uri = uri, + isVisible = !hiddenLayerUrls.contains(uri.toString()), + layerType = it, + ) + } + } else { + null } - } else { - null } - } val networkItems = googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString -> @@ -550,7 +558,7 @@ class MapViewModel( } } - private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) { + private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) { try { val inputStream = application.contentResolver.openInputStream(uri) val directory = File(application.filesDir, "map_layers") @@ -621,7 +629,7 @@ class MapViewModel( } private suspend fun deleteFileToInternalStorage(uri: Uri) { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { try { val file = uri.toFile() if (file.exists()) { @@ -636,11 +644,15 @@ class MapViewModel( @Suppress("Recycle") suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? { val uriToLoad = layerItem.uri ?: return null - return withContext(Dispatchers.IO) { + return withContext(dispatchers.io) { try { if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) { - val url = java.net.URL(uriToLoad.toString()) - java.io.BufferedInputStream(url.openStream()) + val response = httpClient.get(uriToLoad.toString()) + if (!response.status.isSuccess()) { + Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" } + return@withContext null + } + response.bodyAsChannel().toInputStream() } else { application.contentResolver.openInputStream(uriToLoad) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt index e33fb1f8c..668dedbaa 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt @@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers @Module @ComponentScan("org.meshtastic.app.map") @@ -36,9 +36,10 @@ class GoogleMapsKoinModule { @Single @Named("GoogleMapsDataStore") - fun provideGoogleMapsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, - ) + fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, + ) } diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 03549c0b3..d86df9d60 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -48,8 +48,8 @@ import coil3.compose.setSingletonImageLoaderFactory import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.android.inject -import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import org.meshtastic.app.intro.AnalyticsIntro import org.meshtastic.app.map.getMapViewProvider diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt index 4aa27bf0e..dd7e9d8be 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt @@ -31,6 +31,8 @@ import coil3.util.DebugLogger import coil3.util.Logger import io.ktor.client.HttpClient import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging @@ -40,6 +42,7 @@ import okio.Path.Companion.toOkioPath import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.common.BuildConfigProvider +import org.meshtastic.core.network.HttpClientDefaults import org.meshtastic.core.network.KermitHttpLogger private const val DISK_CACHE_PERCENT = 0.02 @@ -84,6 +87,15 @@ class NetworkModule { fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient = HttpClient(engineFactory = Android) { install(plugin = ContentNegotiation) { json(json) } + install(plugin = HttpTimeout) { + requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + } + install(plugin = HttpRequestRetry) { + retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES) + exponentialDelay() + } if (buildConfigProvider.isDebug) { install(plugin = Logging) { logger = KermitHttpLogger diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt index fcdc079f2..c1e399c97 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt @@ -17,18 +17,15 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao -import androidx.room3.Insert -import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Upsert import org.meshtastic.core.database.entity.DeviceHardwareEntity @Dao interface DeviceHardwareDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(deviceHardware: DeviceHardwareEntity) + @Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(deviceHardware: List) + @Upsert suspend fun insertAll(deviceHardware: List) @Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel") suspend fun getByHwModel(hwModel: Int): List diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt index 0a5520a07..040941a49 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt @@ -17,16 +17,14 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao -import androidx.room3.Insert -import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Upsert import org.meshtastic.core.database.entity.FirmwareReleaseEntity import org.meshtastic.core.database.entity.FirmwareReleaseType @Dao interface FirmwareReleaseDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity) + @Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity) @Query("DELETE FROM firmware_release") suspend fun deleteAll() diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index e11d10f50..eb3c27b7e 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -17,9 +17,7 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao -import androidx.room3.Insert import androidx.room3.MapColumn -import androidx.room3.OnConflictStrategy import androidx.room3.Query import androidx.room3.Transaction import androidx.room3.Upsert @@ -168,8 +166,7 @@ interface NodeInfoDao { @Query("SELECT * FROM my_node") fun getMyNodeInfo(): Flow - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun setMyNodeInfo(myInfo: MyNodeEntity) + @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity) @Query("DELETE FROM my_node") suspend fun clearMyNodeInfo() @@ -295,8 +292,7 @@ interface NodeInfoDao { doUpsert(verifiedNode) } - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun putAll(nodes: List) + @Upsert suspend fun putAll(nodes: List) @Query("UPDATE nodes SET notes = :notes WHERE num = :num") suspend fun setNodeNotes(num: Int, notes: String) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt index 2e7f6c549..fde388ce5 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt @@ -17,9 +17,8 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao -import androidx.room3.Insert -import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import org.meshtastic.core.database.entity.TracerouteNodePositionEntity @@ -32,6 +31,5 @@ interface TracerouteNodePositionDao { @Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid") suspend fun deleteByLogUuid(logUuid: String) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(entities: List) + @Upsert suspend fun insertAll(entities: List) } diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt index 94ef1c605..9de792a84 100644 --- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt +++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt @@ -50,7 +50,7 @@ class PreferencesDataStoreModule { @Named("CorePreferencesDataStore") fun providePreferencesDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), migrations = @@ -66,7 +66,7 @@ class LocalConfigDataStoreModule { @Named("CoreLocalConfigDataStore") fun provideLocalConfigDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule { @Named("CoreModuleConfigDataStore") fun provideModuleConfigDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -104,7 +104,7 @@ class ChannelSetDataStoreModule { @Named("CoreChannelSetDataStore") fun provideChannelSetDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -123,7 +123,7 @@ class LocalStatsDataStoreModule { @Named("CoreLocalStatsDataStore") fun provideLocalStatsDataStore( context: Context, - @Named("DataStoreScope") scope: CoroutineScope, + @Named(DATASTORE_SCOPE) scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt index aa81f1ac6..3cb3cabe8 100644 --- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt +++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt @@ -24,10 +24,17 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.ioDispatcher +/** + * Koin qualifier for the application-scoped [CoroutineScope] shared by all [DataStore] instances. + * + * Used with `@Named(DATASTORE_SCOPE)` in Koin annotations and `named(DATASTORE_SCOPE)` in manual DSL modules. + */ +const val DATASTORE_SCOPE = "DataStoreScope" + @Module @ComponentScan("org.meshtastic.core.datastore") class CoreDatastoreModule { @Single - @Named("DataStoreScope") + @Named(DATASTORE_SCOPE) fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt new file mode 100644 index 000000000..db558bedb --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network + +/** + * Shared HTTP client configuration constants used by both Android and Desktop Ktor `HttpClient` setups. + * + * These values are consumed by the platform-specific Koin modules (`NetworkModule` on Android, `DesktopKoinModule` on + * Desktop) when installing [io.ktor.client.plugins.HttpTimeout] and [io.ktor.client.plugins.HttpRequestRetry]. + */ +object HttpClientDefaults { + /** Timeout in milliseconds for connect, request, and socket operations. */ + const val TIMEOUT_MS = 30_000L + + /** Maximum number of automatic retries on server errors (5xx). */ + const val MAX_RETRIES = 3 +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt index 966569f4f..36c26c879 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt @@ -20,12 +20,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.PacketRepository @@ -38,7 +38,9 @@ class MarkAsReadReceiver : private val serviceNotifications: MeshServiceNotifications by inject() - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dispatchers: CoroutineDispatchers by inject() + + private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) } companion object { const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ" diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 028030f76..5869ce94f 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -25,11 +25,11 @@ import android.os.IBinder import androidx.core.app.ServiceCompat import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.koin.android.ext.android.inject import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.common.util.toRemoteExceptions +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.MeshUser @@ -84,8 +84,10 @@ class MeshService : Service() { private val router: MeshRouter by inject() + private val dispatchers: CoroutineDispatchers by inject() + private val serviceJob = Job() - private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) } private var isServiceInitialized = false diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt index 5965b9ddd..f4db74403 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt @@ -21,11 +21,11 @@ import android.content.Context import android.content.Intent import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.ServiceRepository @@ -41,7 +41,9 @@ class ReactionReceiver : private val serviceRepository: ServiceRepository by inject() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val dispatchers: CoroutineDispatchers by inject() + + private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) } @Suppress("TooGenericExceptionCaught", "ReturnCount") override fun onReceive(context: Context, intent: Intent) { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt index 4e82a735d..d7a943783 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt @@ -21,11 +21,11 @@ import android.content.Context import android.content.Intent import androidx.core.app.RemoteInput import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MeshServiceNotifications @@ -44,7 +44,9 @@ class ReplyReceiver : private val meshServiceNotifications: MeshServiceNotifications by inject() - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dispatchers: CoroutineDispatchers by inject() + + private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) } companion object { const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION" diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index d07a5afc3..44b483c91 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -55,7 +55,6 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite) - implementation(libs.jetbrains.navigationevent.compose) implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.compose.material3.adaptive.navigation3) implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index f8b0586f4..aa47539bb 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -27,17 +27,18 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import org.meshtastic.core.common.util.nowMillis @Composable actual fun rememberTimeTickWithLifecycle(): Long { val context = LocalContext.current - var value by remember { mutableLongStateOf(System.currentTimeMillis()) } + var value by remember { mutableLongStateOf(nowMillis) } DisposableEffect(context) { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - value = System.currentTimeMillis() + value = nowMillis } } diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index 22f84b217..165262170 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.ui.component import androidx.compose.runtime.Composable +import org.meshtastic.core.common.util.nowMillis -/** JVM implementation — returns System.currentTimeMillis() (no lifecycle-based updates on Desktop). */ -@Composable actual fun rememberTimeTickWithLifecycle(): Long = System.currentTimeMillis() +/** JVM implementation — returns the current epoch millis (no lifecycle-based updates on Desktop). */ +@Composable actual fun rememberTimeTickWithLifecycle(): Long = nowMillis diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 26fa16f6e..e3c7f8b19 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.desktop +import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -25,15 +26,22 @@ import org.meshtastic.core.repository.NotificationPrefs import androidx.compose.ui.window.Notification as ComposeNotification /** - * Desktop notification manager. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid - * double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. + * Desktop notification manager that bridges domain [Notification] objects to Compose Desktop tray notifications. + * + * Notifications are emitted via [notifications] and collected by the tray composable in [Main.kt]. Respects user + * preferences for message, node-event, and low-battery categories. + * + * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the + * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. */ class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager { init { - co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" } + Logger.i { "DesktopNotificationManager initialized" } } private val _notifications = MutableSharedFlow(extraBufferCapacity = 10) + + /** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */ val notifications: SharedFlow = _notifications.asSharedFlow() override fun dispatch(notification: Notification) { @@ -46,9 +54,7 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific Notification.Category.Service -> true } - co.touchlab.kermit.Logger.d { - "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" - } + Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" } if (!enabled) return @@ -61,14 +67,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific } val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType)) - co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" } + Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" } } override fun cancel(id: Int) { - // Desktop Tray notifications cannot be cancelled once sent via TrayState + // Desktop tray notifications cannot be cancelled once sent via TrayState. } override fun cancelAll() { - // Desktop Tray notifications cannot be cleared once sent via TrayState + // Desktop tray notifications cannot be cleared once sent via TrayState. } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 0a450c007..25a5b8ce3 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -18,7 +18,6 @@ package org.meshtastic.desktop import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -27,22 +26,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberWindowState @@ -55,13 +54,19 @@ import coil3.memory.MemoryCache import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder +import coil3.util.DebugLogger import io.ktor.client.HttpClient import kotlinx.coroutines.flow.first import okio.Path.Companion.toPath -import org.jetbrains.skia.Image +import org.jetbrains.compose.resources.decodeToSvgPainter +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.database.desktopDataDir +import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack @@ -75,33 +80,50 @@ import org.meshtastic.desktop.di.desktopPlatformModule import org.meshtastic.desktop.ui.DesktopMainScreen import java.awt.Desktop import java.util.Locale +import coil3.util.Logger as CoilLogger /** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */ -private val LocalAppLocale = staticCompositionLocalOf { "" } - private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB +/** + * Loads an SVG from JVM classpath resources and returns a [Painter]. + * + * Uses the CMP 1.11 `decodeToSvgPainter` extension which replaces the deprecated `useResource`/`loadSvgPainter` pair. + * The SVG bytes are read from the classpath because CMP `composeResources/` only supports XML vector drawables and + * raster images — not raw SVGs. Since the desktop module is a JVM-only host shell, classpath resource access is safe. + */ @Composable -private fun classpathPainterResource(path: String): Painter { - val bitmap: ImageBitmap = - remember(path) { - val bytes = Thread.currentThread().contextClassLoader!!.getResourceAsStream(path)!!.readAllBytes() - Image.makeFromEncoded(bytes).toComposeImageBitmap() +private fun svgPainterResource(path: String, density: Density): Painter = remember(path, density) { + val classLoader = + requireNotNull(Thread.currentThread().contextClassLoader) { + "Missing context class loader while loading resource: $path" } - return remember(bitmap) { BitmapPainter(bitmap) } + val bytes = + requireNotNull(classLoader.getResourceAsStream(path)) { "Missing classpath resource: $path" } + .use { it.readAllBytes() } + bytes.decodeToSvgPainter(density) } -@Suppress("LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalCoilApi::class) fun main(args: Array) = application(exitProcessOnExit = false) { Logger.i { "Meshtastic Desktop — Starting" } - val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } } - val systemLocale = remember { Locale.getDefault() } - val uiViewModel = remember { koinApp.koin.get() } - val httpClient = remember { koinApp.koin.get() } + remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } } + DisposableEffect(Unit) { onDispose { stopKoin() } } + val uiViewModel = koinViewModel() + + DeepLinkHandler(args, uiViewModel) + MeshServiceLifecycle() + ThemeAndLocaleProvider(uiViewModel) +} + +// ----- Deep link handling ----- + +/** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */ +@Composable +private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: UIViewModel) { LaunchedEffect(args) { args.forEach { arg -> if ( @@ -124,14 +146,28 @@ fun main(args: Array) = application(exitProcessOnExit = false) { } } } +} - val meshServiceController = remember { koinApp.koin.get() } +// ----- Mesh service lifecycle ----- + +/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal. */ +@Composable +private fun MeshServiceLifecycle() { + val meshServiceController = koinInject() DisposableEffect(Unit) { meshServiceController.start() onDispose { meshServiceController.stop() } } +} - val uiPrefs = remember { koinApp.koin.get() } +// ----- Theme, locale, and application shell ----- + +/** Resolves the user's theme/locale preferences and renders the full application UI. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { + val systemLocale = remember { Locale.getDefault() } + val uiPrefs = koinInject() val themePref by uiPrefs.theme.collectAsState(initial = -1) val localePref by uiPrefs.locale.collectAsState(initial = "") @@ -144,25 +180,59 @@ fun main(args: Array) = application(exitProcessOnExit = false) { else -> isSystemInDarkTheme() } + MeshtasticDesktopApp(uiViewModel, isDarkTheme) +} + +// ----- Application chrome (tray, window, navigation) ----- + +/** Composes the system tray, window, and Coil image loader. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDarkTheme: Boolean) { var isAppVisible by remember { mutableStateOf(true) } var isWindowReady by remember { mutableStateOf(false) } val trayState = rememberTrayState() - val appIcon = classpathPainterResource("icon.png") + val density = LocalDensity.current + val appIcon = svgPainterResource("tray_icon_black.svg", density) - @Suppress("DEPRECATION") val trayIcon = - androidx.compose.ui.res.painterResource( - if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", - ) + svgPainterResource(if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", density) - val notificationManager = remember { koinApp.koin.get() } - val desktopPrefs = remember { koinApp.koin.get() } + val notificationManager = koinInject() + val desktopPrefs = koinInject() val windowState = rememberWindowState() LaunchedEffect(Unit) { notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) } } + WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true } + + Tray( + state = trayState, + icon = trayIcon, + tooltip = "Meshtastic Desktop", + onAction = { isAppVisible = true }, + menu = { + Item("Show Meshtastic", onClick = { isAppVisible = true }) + Item("Quit", onClick = ::exitApplication) + }, + ) + + if (isWindowReady && isAppVisible) { + MeshtasticWindow(uiViewModel, isDarkTheme, appIcon, windowState) { isAppVisible = false } + } +} + +// ----- Window bounds persistence ----- + +/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */ +@Composable +private fun WindowBoundsManager( + desktopPrefs: DesktopPreferencesDataSource, + windowState: WindowState, + onReady: () -> Unit, +) { LaunchedEffect(Unit) { val initialWidth = desktopPrefs.windowWidth.first() val initialHeight = desktopPrefs.windowHeight.first() @@ -177,7 +247,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) { WindowPosition(Alignment.Center) } - isWindowReady = true + onReady() snapshotFlow { val x = if (windowState.position.isSpecified) windowState.position.x.value else Float.NaN @@ -188,86 +258,99 @@ fun main(args: Array) = application(exitProcessOnExit = false) { desktopPrefs.setWindowBounds(width = bounds[0], height = bounds[1], x = bounds[2], y = bounds[3]) } } +} - Tray( - state = trayState, - icon = trayIcon, - tooltip = "Meshtastic Desktop", - onAction = { isAppVisible = true }, - menu = { - Item("Show Meshtastic", onClick = { isAppVisible = true }) - Item("Quit", onClick = ::exitApplication) - }, - ) +// ----- Main window with keyboard shortcuts and Coil ----- - if (isWindowReady && isAppVisible) { - val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) - val backStack = multiBackstack.activeBackStack +/** Renders the main application window with keyboard shortcuts, Coil image loading, and the Compose UI tree. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.MeshtasticWindow( + uiViewModel: UIViewModel, + isDarkTheme: Boolean, + appIcon: Painter, + windowState: WindowState, + onCloseRequest: () -> Unit, +) { + val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) - Window( - onCloseRequest = { isAppVisible = false }, - title = "Meshtastic Desktop", - icon = appIcon, - state = windowState, - onPreviewKeyEvent = { event -> - if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false - when { - event.key == Key.Q -> { - exitApplication() - true - } - event.key == Key.Comma -> { - if ( - TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull()) - ) { - multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) - } - true - } - event.key == Key.One -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) - true - } - event.key == Key.Two -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) - true - } - event.key == Key.Three -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) - true - } - event.key == Key.Four -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) - true - } - event.key == Key.Slash -> { - backStack.add(SettingsRoute.About) - true - } - else -> false - } - }, - ) { - setSingletonImageLoaderFactory { context -> - val cacheDir = desktopDataDir() + "/image_cache_v3" - ImageLoader.Builder(context) - .components { - add(KtorNetworkFetcherFactory(httpClient = httpClient)) - // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts - // that show up as solid/black hardware images. - add(SvgDecoder.Factory(renderToBitmap = true)) - } - .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } - .diskCache { - DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() - } - .crossfade(true) - .build() - } - - CompositionLocalProvider(LocalAppLocale provides localePref) { - AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } - } - } + Window( + onCloseRequest = onCloseRequest, + title = "Meshtastic Desktop", + icon = appIcon, + state = windowState, + onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) }, + ) { + CoilImageLoaderSetup() + AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } + } +} + +/** Configures the Coil singleton [ImageLoader] with Ktor networking, SVG decoding, and caching. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun CoilImageLoaderSetup() { + val httpClient = koinInject() + val buildConfigProvider = koinInject() + + setSingletonImageLoaderFactory { context -> + val cacheDir = desktopDataDir() + "/image_cache_v3" + ImageLoader.Builder(context) + .components { + add(KtorNetworkFetcherFactory(httpClient = httpClient)) + // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts + // that show up as solid/black hardware images. + add(SvgDecoder.Factory(renderToBitmap = true)) + } + .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } + .diskCache { DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() } + .logger(if (buildConfigProvider.isDebug) DebugLogger(minLevel = CoilLogger.Level.Verbose) else null) + .crossfade(true) + .build() + } +} + +// ----- Keyboard shortcuts ----- + +/** Handles Cmd-key shortcuts. Returns `true` if the event was consumed. */ +private fun handleKeyboardShortcut( + event: androidx.compose.ui.input.key.KeyEvent, + multiBackstack: MultiBackstack, + exitApplication: () -> Unit, +): Boolean { + if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return false + val backStack = multiBackstack.activeBackStack + return when (event.key) { + Key.Q -> { + exitApplication() + true + } + Key.Comma -> { + if (TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())) { + multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) + } + true + } + Key.One -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) + true + } + Key.Two -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) + true + } + Key.Three -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) + true + } + Key.Four -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) + true + } + Key.Slash -> { + backStack.add(SettingsRoute.About) + true + } + else -> false } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt index 9af34f28d..6dd562bd4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt @@ -21,7 +21,6 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -30,16 +29,21 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers -const val KEY_WINDOW_WIDTH = "window_width" -const val KEY_WINDOW_HEIGHT = "window_height" -const val KEY_WINDOW_X = "window_x" -const val KEY_WINDOW_Y = "window_y" - +/** + * Persists and restores desktop window geometry (position and size) across application restarts. + * + * Backed by the `CorePreferencesDataStore` [DataStore] instance. Window bounds are written atomically via + * [setWindowBounds] and exposed as [StateFlow] properties for composable consumption. + */ @Single -class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore) { +class DesktopPreferencesDataSource( + @Named("CorePreferencesDataStore") private val dataStore: DataStore, + dispatchers: CoroutineDispatchers, +) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) val windowWidth: StateFlow = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f) val windowHeight: StateFlow = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f) @@ -64,9 +68,9 @@ class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private va ): StateFlow = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default) companion object { - val WINDOW_WIDTH = floatPreferencesKey(KEY_WINDOW_WIDTH) - val WINDOW_HEIGHT = floatPreferencesKey(KEY_WINDOW_HEIGHT) - val WINDOW_X = floatPreferencesKey(KEY_WINDOW_X) - val WINDOW_Y = floatPreferencesKey(KEY_WINDOW_Y) + val WINDOW_WIDTH = floatPreferencesKey("window_width") + val WINDOW_HEIGHT = floatPreferencesKey("window_height") + val WINDOW_X = floatPreferencesKey("window_x") + val WINDOW_Y = floatPreferencesKey("window_y") } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt index 0bb5311aa..d27f6d5d9 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt @@ -19,6 +19,10 @@ package org.meshtastic.desktop.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +/** + * Koin module that component-scans the `org.meshtastic.desktop` package for annotated bindings (`@Single`, `@Factory`, + * `@KoinViewModel`). + */ @Module @ComponentScan("org.meshtastic.desktop") class DesktopDiModule diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 336f87b54..5b3b03f9d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -14,11 +14,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("ktlint:standard:no-unused-imports") // Koin KSP-generated extension functions require aliased imports + package org.meshtastic.desktop.di // Generated Koin module extensions from core KMP modules import io.ktor.client.HttpClient import io.ktor.client.engine.java.Java +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging @@ -32,6 +36,7 @@ import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.RadioController +import org.meshtastic.core.network.HttpClientDefaults import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.network.service.ApiService @@ -163,7 +168,7 @@ private fun desktopPlatformStubsModule() = module { single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } single { NoopMeshWorkerManager() } - single { DesktopMessageQueue(packetRepository = get(), radioController = get()) } + single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) } single { NoopMeshLocationManager() } single { NoopLocationRepository() } single { NoopMQTTRepository() } @@ -178,6 +183,15 @@ private fun desktopPlatformStubsModule() = module { single { HttpClient(Java) { install(ContentNegotiation) { json(get()) } + install(HttpTimeout) { + requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES) + exponentialDelay() + } if (DesktopBuildConfig.IS_DEBUG) { install(Logging) { logger = KermitHttpLogger diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt index 6b0aa1b2a..743c2065d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -27,7 +27,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okio.FileSystem import okio.Path.Companion.toPath @@ -35,10 +34,12 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.database.desktopDataDir +import org.meshtastic.core.datastore.di.DATASTORE_SCOPE import org.meshtastic.core.datastore.serializer.ChannelSetSerializer import org.meshtastic.core.datastore.serializer.LocalConfigSerializer import org.meshtastic.core.datastore.serializer.LocalStatsSerializer import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.LocalConfig @@ -49,10 +50,10 @@ import org.meshtastic.proto.LocalStats private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore { val dir = desktopDataDir() + "/datastore" FileSystem.SYSTEM.createDirectories(dir.toPath()) - return PreferenceDataStoreFactory.create( + return PreferenceDataStoreFactory.createWithPath( corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), scope = scope, - produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath().toFile() }, + produceFile = { "$dir/$name.preferences_pb".toPath() }, ) } @@ -80,16 +81,15 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner { * - [Lifecycle] (`ProcessLifecycle`) * - [BuildConfigProvider] */ -@Suppress("InjectDispatcher") fun desktopPlatformModule() = module { // Application-lifetime scope shared by all DataStore instances. Per the DataStore docs: // "The Job within this context dictates the lifecycle of the DataStore's internal operations. // Ensure it is an application-scoped context that is not canceled by UI lifecycle events." // DataStore has no close() API — the in-memory cache is released only when this Job is cancelled // (at process exit). Using SupervisorJob so a single store's failure doesn't cascade. - val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + single(named(DATASTORE_SCOPE)) { CoroutineScope(get().io + SupervisorJob()) } - includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope)) + includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule()) // -- Build config (values generated at build time by generateDesktopBuildConfig) -- single { @@ -108,30 +108,50 @@ fun desktopPlatformModule() = module { } /** Named [DataStore]<[Preferences]> instances for all preference domains. */ -private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module { - single>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) } +private fun desktopPreferencesDataStoreModule() = module { + single>(named("AnalyticsDataStore")) { + createPreferencesDataStore("analytics", get(named(DATASTORE_SCOPE))) + } single>(named("HomoglyphEncodingDataStore")) { - createPreferencesDataStore("homoglyph_encoding", scope) + createPreferencesDataStore("homoglyph_encoding", get(named(DATASTORE_SCOPE))) + } + single>(named("AppDataStore")) { + createPreferencesDataStore("app", get(named(DATASTORE_SCOPE))) + } + single>(named("CustomEmojiDataStore")) { + createPreferencesDataStore("custom_emoji", get(named(DATASTORE_SCOPE))) + } + single>(named("MapDataStore")) { + createPreferencesDataStore("map", get(named(DATASTORE_SCOPE))) + } + single>(named("MapConsentDataStore")) { + createPreferencesDataStore("map_consent", get(named(DATASTORE_SCOPE))) } - single>(named("AppDataStore")) { createPreferencesDataStore("app", scope) } - single>(named("CustomEmojiDataStore")) { createPreferencesDataStore("custom_emoji", scope) } - single>(named("MapDataStore")) { createPreferencesDataStore("map", scope) } - single>(named("MapConsentDataStore")) { createPreferencesDataStore("map_consent", scope) } single>(named("MapTileProviderDataStore")) { - createPreferencesDataStore("map_tile_provider", scope) + createPreferencesDataStore("map_tile_provider", get(named(DATASTORE_SCOPE))) + } + single>(named("MeshDataStore")) { + createPreferencesDataStore("mesh", get(named(DATASTORE_SCOPE))) + } + single>(named("RadioDataStore")) { + createPreferencesDataStore("radio", get(named(DATASTORE_SCOPE))) + } + single>(named("UiDataStore")) { + createPreferencesDataStore("ui", get(named(DATASTORE_SCOPE))) + } + single>(named("MeshLogDataStore")) { + createPreferencesDataStore("meshlog", get(named(DATASTORE_SCOPE))) + } + single>(named("FilterDataStore")) { + createPreferencesDataStore("filter", get(named(DATASTORE_SCOPE))) } - single>(named("MeshDataStore")) { createPreferencesDataStore("mesh", scope) } - single>(named("RadioDataStore")) { createPreferencesDataStore("radio", scope) } - single>(named("UiDataStore")) { createPreferencesDataStore("ui", scope) } - single>(named("MeshLogDataStore")) { createPreferencesDataStore("meshlog", scope) } - single>(named("FilterDataStore")) { createPreferencesDataStore("filter", scope) } single>(named("CorePreferencesDataStore")) { - createPreferencesDataStore("core_preferences", scope) + createPreferencesDataStore("core_preferences", get(named(DATASTORE_SCOPE))) } } /** Proto [DataStore] instances (OkioStorage-backed). */ -private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { +private fun desktopProtoDataStoreModule() = module { val protoDir = desktopDataDir() + "/datastore" single>(named("CoreLocalConfigDataStore")) { @@ -143,7 +163,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/local_config.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -156,7 +176,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/module_config.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -169,7 +189,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/channel_set.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -182,7 +202,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/local_stats.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index f30ecb66b..594a62bc4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -19,6 +19,7 @@ package org.meshtastic.desktop.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.map.navigation.mapGraph @@ -29,42 +30,22 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph /** - * Registers entry providers for all top-level desktop destinations. + * Registers [NavKey] entry providers for every desktop destination. * - * Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from - * `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via - * [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their - * shared composables are wired. + * Each call delegates to the shared navigation graph extension exported by the corresponding feature module, keeping + * the desktop shell free of screen-level composable knowledge. */ -fun EntryProviderScope.desktopNavGraph( - backStack: NavBackStack, - uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel, -) { - // Nodes — real composables from feature:node +fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack, uiViewModel: UIViewModel) { nodesGraph( backStack = backStack, scrollToTopEvents = uiViewModel.scrollToTopEventFlow, onHandleDeepLink = uiViewModel::handleDeepLink, ) - - // Conversations — real composables from feature:messaging contactsGraph(backStack, uiViewModel.scrollToTopEventFlow) - - // Map — placeholder for now, will be replaced with feature:map real implementation mapGraph(backStack) - - // Firmware — in-flow destination (for example from Settings), not a top-level rail tab firmwareGraph(backStack) - - // Settings — real composables from feature:settings settingsGraph(backStack) - - // Channels channelsGraph(backStack) - - // Connections — shared screen connectionsGraph(backStack) - - // WiFi Provisioning — nymea-networkmanager BLE protocol wifiProvisionGraph(backStack) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index a5ec5b795..309fff7da 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.desktop.notification +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.Notification @@ -29,8 +30,15 @@ import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry /** - * Desktop notifications implementation. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to - * avoid double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. + * Desktop implementation of [MeshServiceNotifications]. + * + * Converts mesh-layer notification events into domain [Notification] objects and dispatches them through + * [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications. + * + * Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops. + * + * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the + * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. */ @Suppress("TooManyFunctions") class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications { @@ -39,14 +47,11 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat } override fun initChannels() { - // no-op for desktop + // No-op: desktop has no Android notification channels. } - override fun updateServiceStateNotification( - state: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ) { - // We don't have a foreground service on desktop + override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) { + // No-op: desktop has no foreground service notification. } override suspend fun updateMessageNotification( @@ -106,16 +111,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat ) } - @Suppress("ktlint:standard:max-line-length") override fun showAlertNotification(contactKey: String, name: String, alert: String) { - notificationManager.dispatch( - Notification( - title = name, - message = alert, - category = Notification.Category.Alert, - contactKey = contactKey, - ), - ) + val notification = + Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey) + notificationManager.dispatch(notification) } override fun showNewNodeSeenNotification(node: Node) { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt index c272e7bd9..3888b0af3 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt @@ -18,9 +18,9 @@ package org.meshtastic.desktop.radio import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.RadioController @@ -36,8 +36,9 @@ import org.meshtastic.core.repository.PacketRepository class DesktopMessageQueue( private val packetRepository: PacketRepository, private val radioController: RadioController, + dispatchers: CoroutineDispatchers, ) : MessageQueue { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) override suspend fun enqueue(packetId: Int) { scope.launch { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt index 5e223ed67..b0761522d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt @@ -24,15 +24,18 @@ import org.meshtastic.feature.node.compass.MagneticFieldProvider import org.meshtastic.feature.node.compass.PhoneLocationProvider import org.meshtastic.feature.node.compass.PhoneLocationState +/** No-op [CompassHeadingProvider] — desktop has no compass sensor. */ class NoopCompassHeadingProvider : CompassHeadingProvider { override fun headingUpdates(): Flow = flowOf(HeadingState(hasSensor = false)) } +/** No-op [PhoneLocationProvider] — desktop has no GPS provider. */ class NoopPhoneLocationProvider : PhoneLocationProvider { override fun locationUpdates(): Flow = flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false)) } +/** No-op [MagneticFieldProvider] — always returns zero declination. */ class NoopMagneticFieldProvider : MagneticFieldProvider { override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 00b2e82c7..a55bf902f 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -31,7 +31,10 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.desktop.navigation.desktopNavGraph -/** Desktop main screen — uses shared navigation components. */ +/** + * Desktop main screen — assembles the shared [MeshtasticAppShell], [MeshtasticNavigationSuite], and + * [MeshtasticNavDisplay] with the desktop-specific [desktopNavGraph] entry provider. + */ @Composable fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) { val backStack = multiBackstack.activeBackStack diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index 80eed61c5..f2887d98a 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -42,7 +42,6 @@ kotlin { implementation(projects.core.ui) implementation(libs.jetbrains.navigation3.ui) - implementation(libs.jetbrains.navigationevent.compose) implementation(libs.androidx.paging.common) implementation(libs.androidx.paging.compose) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 230e6533f..2c9978463 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,6 @@ glance = "1.2.0-rc01" lifecycle = "2.10.0" jetbrains-lifecycle = "2.11.0-alpha03" navigation3 = "1.1.0-rc01" -navigationevent = "1.1.0-alpha01" paging = "3.4.2" room = "3.0.0-alpha03" koin = "4.2.1" @@ -112,7 +111,6 @@ jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecyc jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } -jetbrains-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" } androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" } From 3aadd29e67e56656d7d4bd37d1b6ed442980b3a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:17:49 -0500 Subject: [PATCH 057/114] chore(deps): update core/proto/src/main/proto digest to a045501 (#5124) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- core/proto/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index 98e95eeaa..a045501ea 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 98e95eeaa26770e6ede0291753623e4744b6ede1 +Subproject commit a045501ea848f49d546cc10e4c162a32317d4c7e From 27055290e2a7a79ecf0b2e017684a43280b9e2b5 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:37:12 -0500 Subject: [PATCH 058/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5125) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../src/commonMain/composeResources/values-bg/strings.xml | 3 +++ .../src/commonMain/composeResources/values-ru/strings.xml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index 56f32b1ba..14fc7aae5 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -469,6 +469,9 @@ Известия при получаване на сигнал/позвъняване Използване на PWM зумер Тон на звънене + Импортирана мелодия + Файлът е празен + Грешка при импортиране: %1$s LoRa Опции Разширени diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index a201c1dc8..ef0e89a45 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -590,6 +590,9 @@ Продолжительность вывода (миллисекунды) Таймаут Nag (в секундах) Рингтон + Импортировать рингтон + Файл пуст + Ошибка импорта: %1$s Воспроизвести Использовать I2S как буззер LoRa From c6f58cc7994506ca527a2d719a0217c0f7174415 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:48:25 -0500 Subject: [PATCH 059/114] chore(deps): update core/proto/src/main/proto digest to 940ac38 (#5126) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- core/proto/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index a045501ea..940ac382a 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit a045501ea848f49d546cc10e4c162a32317d4c7e +Subproject commit 940ac382a7d143040da5a880237f84c48ee31f2b From 099aea2d81655d355b5ffdc8a7a2fac447861a09 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:16:10 -0500 Subject: [PATCH 060/114] feat(desktop): add entitlements and wire MeshConnectionManager into orchestrator (#5127) --- .../core/service/MeshServiceOrchestrator.kt | 3 +++ .../core/service/MeshServiceOrchestratorTest.kt | 3 +++ desktop/build.gradle.kts | 5 +++++ desktop/entitlements.plist | 14 ++++++++++++++ .../main/kotlin/org/meshtastic/desktop/Main.kt | 15 +++++++-------- 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 desktop/entitlements.plist diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index e651d95ce..50e88cc3f 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -26,6 +26,7 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MeshServiceNotifications @@ -57,6 +58,7 @@ class MeshServiceOrchestrator( private val takMeshIntegration: TAKMeshIntegration, private val takPrefs: TakPrefs, private val databaseManager: DatabaseManager, + private val connectionManager: MeshConnectionManager, @Named("ServiceScope") private val scope: CoroutineScope, ) { private var serviceJob: Job? = null @@ -87,6 +89,7 @@ class MeshServiceOrchestrator( serviceJob = job serviceNotifications.initChannels() + connectionManager.updateStatusNotification() // Observe TAK server pref to start/stop takJob = diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 48be7dbf6..ddb7b148f 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -35,6 +35,7 @@ import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshActionHandler import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MeshServiceNotifications @@ -67,6 +68,7 @@ class MeshServiceOrchestratorTest { private val takPrefs: TakPrefs = mock(MockMode.autofill) private val cotHandler: CoTHandler = mock(MockMode.autofill) private val databaseManager: DatabaseManager = mock(MockMode.autofill) + private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) private val testDispatcher = UnconfinedTestDispatcher() private val testScope = CoroutineScope(testDispatcher) @@ -111,6 +113,7 @@ class MeshServiceOrchestratorTest { takMeshIntegration = takMeshIntegration, takPrefs = takPrefs, databaseManager = databaseManager, + connectionManager = connectionManager, scope = testScope, ) } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index df5122a4d..fdf7cee5c 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -158,9 +158,14 @@ compose.desktop { iconFile.set(project.file("src/main/resources/icon.icns")) minimumSystemVersion = "12.0" bundleID = "org.meshtastic.desktop" + entitlementsFile.set(project.file("entitlements.plist")) infoPlist { extraKeysRawXml = """ + NSBluetoothAlwaysUsageDescription + Meshtastic uses Bluetooth to communicate with your Meshtastic radio device. + NSLocalNetworkUsageDescription + Meshtastic uses your local network to discover Meshtastic devices connected via WiFi. NSUserNotificationAlertStyle alert CFBundleURLTypes diff --git a/desktop/entitlements.plist b/desktop/entitlements.plist new file mode 100644 index 000000000..f799a66e9 --- /dev/null +++ b/desktop/entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.bluetooth + + + diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 25a5b8ce3..8b33a3612 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -60,9 +60,7 @@ import kotlinx.coroutines.flow.first import okio.Path.Companion.toPath import org.jetbrains.compose.resources.decodeToSvgPainter import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel import org.koin.core.context.startKoin -import org.koin.core.context.stopKoin import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.database.desktopDataDir @@ -107,12 +105,13 @@ private fun svgPainterResource(path: String, density: Density): Painter = rememb @OptIn(ExperimentalCoilApi::class) fun main(args: Array) = application(exitProcessOnExit = false) { - Logger.i { "Meshtastic Desktop — Starting" } - - remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } } - DisposableEffect(Unit) { onDispose { stopKoin() } } - - val uiViewModel = koinViewModel() + val koinApp = remember { + Logger.i { "Meshtastic Desktop — Starting" } + startKoin { modules(desktopPlatformModule(), desktopModule()) } + } + val systemLocale = remember { Locale.getDefault() } + val uiViewModel = remember { koinApp.koin.get() } + val httpClient = remember { koinApp.koin.get() } DeepLinkHandler(args, uiViewModel) MeshServiceLifecycle() From f48fc61729b3f6f465c2a3bfd47d21114a9bd2bb Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:03:24 -0500 Subject: [PATCH 061/114] feat(environment): add 1-Wire multi-thermometer (DS18B20) display support (#5130) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../composeResources/values/strings.xml | 1 + .../meshtastic/core/ui/theme/CustomColors.kt | 4 ++ .../node/component/EnvironmentMetrics.kt | 13 ++++ .../feature/node/metrics/CommonCharts.kt | 7 +- .../feature/node/metrics/EnvironmentCharts.kt | 26 ++++++- .../node/metrics/EnvironmentMetrics.kt | 35 ++++++++++ .../node/metrics/EnvironmentMetricsState.kt | 69 ++++++++++++++++++- .../feature/node/metrics/MetricsViewModel.kt | 14 +++- 8 files changed, 162 insertions(+), 7 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 4a5e40ade..9678c9919 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -748,6 +748,7 @@ Rain (24h) Weight Radiation + 1-Wire Temp Indoor Air Quality (IAQ) URL diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt index 240c01503..d2047b603 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt @@ -60,6 +60,10 @@ object GraphColors { val Lime = Color(0xFFCDDC39) val Indigo = Color(0xFF3F51B5) val DeepOrange = Color(0xFFFF5722) + val Magenta = Color(0xFFE040FB) + val SkyBlue = Color(0xFF03A9F4) + val Chartreuse = Color(0xFF76FF03) + val Coral = Color(0xFFFF6E40) } object StatusColors { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt index aa44a6b7e..067d9cf40 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.resources.ic_radioactive import org.meshtastic.core.resources.ic_soil_moisture import org.meshtastic.core.resources.ic_soil_temperature import org.meshtastic.core.resources.lux +import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.pressure import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.soil_moisture @@ -222,6 +223,18 @@ internal fun EnvironmentMetrics( ), ) } + // 1-Wire temperature sensors (up to 8 channels) + one_wire_temperature + .filterNot { it.isNaN() } + .forEachIndexed { idx, temp -> + add( + DrawableMetricInfo( + label = Res.string.one_wire_temperature, + value = "${idx + 1}: ${temp.toTempString(isFahrenheit)}", + icon = Res.drawable.ic_soil_temperature, + ), + ) + } } } FlowRow( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt index bb6efdff6..f8d48dd59 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -127,6 +127,8 @@ data class LegendData( val color: Color, val isLine: Boolean = false, val metricKey: Any? = null, + /** When non-null, overrides the resolved [nameRes] string in the legend label. */ + val labelOverride: String? = null, ) data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color) @@ -153,11 +155,12 @@ fun Legend( ) { legendData.forEachIndexed { index, data -> val isVisible = index !in hiddenSet + val label = data.labelOverride ?: stringResource(data.nameRes) if (onToggle != null) { FilterChip( selected = isVisible, onClick = { onToggle(index) }, - label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) }, + label = { Text(text = label, style = MaterialTheme.typography.labelSmall) }, leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) }, modifier = Modifier.padding(horizontal = 2.dp), ) @@ -166,7 +169,7 @@ fun Legend( LegendIndicator(color = data.color, isLine = data.isLine) Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(data.nameRes), + text = label, color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelSmall.fontSize, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index c0164dd80..0f809ef81 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -42,6 +42,7 @@ import org.meshtastic.core.resources.baro_pressure import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.lux +import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.soil_moisture import org.meshtastic.core.resources.soil_temperature @@ -112,6 +113,27 @@ private val LEGEND_DATA_3 = ), ) +private val LEGEND_DATA_4 = + listOf( + Environment.ONE_WIRE_TEMP_1, + Environment.ONE_WIRE_TEMP_2, + Environment.ONE_WIRE_TEMP_3, + Environment.ONE_WIRE_TEMP_4, + Environment.ONE_WIRE_TEMP_5, + Environment.ONE_WIRE_TEMP_6, + Environment.ONE_WIRE_TEMP_7, + Environment.ONE_WIRE_TEMP_8, + ) + .mapIndexed { index, entry -> + LegendData( + nameRes = Res.string.one_wire_temperature, + labelOverride = "1-Wire Temp ${index + 1}", + color = entry.color, + isLine = true, + metricKey = entry, + ) + } + @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun EnvironmentMetricsChart( @@ -132,7 +154,7 @@ fun EnvironmentMetricsChart( val onSurfaceColor = MaterialTheme.colorScheme.onSurface val allLegendData = - (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter { + (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4).filter { graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0] } @@ -143,7 +165,7 @@ fun EnvironmentMetricsChart( hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet() } - val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) } + val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) } val showPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && Environment.BAROMETRIC_PRESSURE !in hiddenMetrics diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 4f9e88d47..77c6781f1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -54,6 +54,7 @@ import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.iaq_definition import org.meshtastic.core.resources.lux +import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.rainfall_1h import org.meshtastic.core.resources.rainfall_24h @@ -443,6 +444,39 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) } } +@Composable +private fun OneWireTemperatureDisplay( + envMetrics: org.meshtastic.proto.EnvironmentMetrics, + environmentDisplayFahrenheit: Boolean, +) { + val sensors = envMetrics.one_wire_temperature.filterNot { it.isNaN() } + if (sensors.isEmpty()) return + val oneWireEntries = + listOf( + Environment.ONE_WIRE_TEMP_1, + Environment.ONE_WIRE_TEMP_2, + Environment.ONE_WIRE_TEMP_3, + Environment.ONE_WIRE_TEMP_4, + Environment.ONE_WIRE_TEMP_5, + Environment.ONE_WIRE_TEMP_6, + Environment.ONE_WIRE_TEMP_7, + Environment.ONE_WIRE_TEMP_8, + ) + val textFormat = if (environmentDisplayFahrenheit) "%s %d: %.1f°F" else "%s %d: %.1f°C" + sensors.forEachIndexed { idx, temp -> + val color = oneWireEntries.getOrNull(idx)?.color ?: Environment.ONE_WIRE_TEMP_1.color + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(color) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString(textFormat, stringResource(Res.string.one_wire_temperature), idx + 1, temp), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } +} + @Composable private fun EnvironmentMetricsCard( telemetry: Telemetry, @@ -484,6 +518,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa RadiationDisplay(envMetrics) WindDisplay(envMetrics) RainfallDisplay(envMetrics) + OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt index dda094e21..686a228b2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt @@ -18,16 +18,24 @@ package org.meshtastic.feature.node.metrics import androidx.compose.ui.graphics.Color import org.meshtastic.core.model.util.UnitConversions +import org.meshtastic.core.ui.theme.GraphColors.Amber import org.meshtastic.core.ui.theme.GraphColors.Blue +import org.meshtastic.core.ui.theme.GraphColors.Chartreuse +import org.meshtastic.core.ui.theme.GraphColors.Coral import org.meshtastic.core.ui.theme.GraphColors.Cyan +import org.meshtastic.core.ui.theme.GraphColors.DeepOrange import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.Green +import org.meshtastic.core.ui.theme.GraphColors.Indigo import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue +import org.meshtastic.core.ui.theme.GraphColors.LightGreen import org.meshtastic.core.ui.theme.GraphColors.Lime +import org.meshtastic.core.ui.theme.GraphColors.Magenta import org.meshtastic.core.ui.theme.GraphColors.Orange import org.meshtastic.core.ui.theme.GraphColors.Pink import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.core.ui.theme.GraphColors.Red +import org.meshtastic.core.ui.theme.GraphColors.SkyBlue import org.meshtastic.core.ui.theme.GraphColors.Teal import org.meshtastic.proto.Telemetry @@ -66,7 +74,39 @@ enum class Environment(val color: Color) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.wind_speed }, RADIATION(Lime) { - override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.radiation + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.radiation + }, + ONE_WIRE_TEMP_1(Amber) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(0) + }, + ONE_WIRE_TEMP_2(DeepOrange) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(1) + }, + ONE_WIRE_TEMP_3(Indigo) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(2) + }, + ONE_WIRE_TEMP_4(LightGreen) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(3) + }, + ONE_WIRE_TEMP_5(Magenta) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(4) + }, + ONE_WIRE_TEMP_6(SkyBlue) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(5) + }, + ONE_WIRE_TEMP_7(Chartreuse) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(6) + }, + ONE_WIRE_TEMP_8(Coral) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(7) }, ; abstract fun getValue(telemetry: Telemetry): Float? @@ -205,6 +245,33 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp shouldPlot[Environment.RADIATION.ordinal] = true } + // 1-Wire temperature sensors (up to 8 channels, Fahrenheit-aware) + val oneWireEntries = + listOf( + Environment.ONE_WIRE_TEMP_1, + Environment.ONE_WIRE_TEMP_2, + Environment.ONE_WIRE_TEMP_3, + Environment.ONE_WIRE_TEMP_4, + Environment.ONE_WIRE_TEMP_5, + Environment.ONE_WIRE_TEMP_6, + Environment.ONE_WIRE_TEMP_7, + Environment.ONE_WIRE_TEMP_8, + ) + oneWireEntries.forEach { entry -> + val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } } + if (values.isNotEmpty()) { + var minVal = values.minOf { it } + var maxVal = values.maxOf { it } + if (useFahrenheit) { + minVal = UnitConversions.celsiusToFahrenheit(minVal) + maxVal = UnitConversions.celsiusToFahrenheit(maxVal) + } + minValues.add(minVal) + maxValues.add(maxVal) + shouldPlot[entry.ordinal] = true + } + } + val min = if (minValues.isEmpty()) 0f else minValues.minOf { it } val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index b7ab25368..4967e65d5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -148,6 +148,8 @@ open class MetricsViewModel( temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, soil_temperature = em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, + one_wire_temperature = + em.one_wire_temperature.map { UnitConversions.celsiusToFahrenheit(it) }, ), ) } @@ -381,21 +383,25 @@ open class MetricsViewModel( } fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List) { + val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" } exportCsv( uri = uri, header = "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," + "\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," + - "\"soilMoisture\"\n", + "\"soilMoisture\",$oneWireHeaders\n", rows = data, epochSeconds = { it.time.toLong() }, ) { t -> val em = t.environment_metrics + val owt = em?.one_wire_temperature ?: emptyList() + val oneWireValues = + (0 until ONE_WIRE_SENSOR_COUNT).joinToString(",") { i -> "\"${owt.getOrNull(i) ?: ""}\"" } "\"${em?.temperature ?: ""}\",\"${em?.relative_humidity ?: ""}\"," + "\"${em?.barometric_pressure ?: ""}\",\"${em?.gas_resistance ?: ""}\"," + "\"${em?.iaq ?: ""}\",\"${em?.wind_speed ?: ""}\"," + "\"${em?.wind_direction ?: ""}\",\"${em?.soil_temperature ?: ""}\"," + - "\"${em?.soil_moisture ?: ""}\"" + "\"${em?.soil_moisture ?: ""}\",$oneWireValues" } } @@ -457,4 +463,8 @@ open class MetricsViewModel( } protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0) + + companion object { + private const val ONE_WIRE_SENSOR_COUNT = 8 + } } From fa63a4ac502fa4a6b7b336c3f65406eeefc76a6d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:14:20 -0500 Subject: [PATCH 062/114] feat: add high-contrast theme with accessible message bubbles (#5135) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/app/MainActivity.kt | 4 +- .../settings/SetContrastLevelUseCase.kt | 27 ++++ .../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 8 ++ .../core/repository/AppPreferences.kt | 4 + .../composeResources/values/strings.xml | 5 + .../core/testing/FakeAppPreferences.kt | 6 + .../meshtastic/core/ui/theme/ContrastLevel.kt | 44 +++++++ .../org/meshtastic/core/ui/theme/Theme.kt | 33 +++-- .../core/ui/viewmodel/UIViewModel.kt | 1 + .../kotlin/org/meshtastic/desktop/Main.kt | 18 ++- .../component/MessageActionsBottomSheet.kt | 3 +- .../messaging/component/MessageItem.kt | 117 +++++++++++------- .../feature/messaging/component/Reaction.kt | 9 +- .../feature/settings/SettingsScreen.kt | 10 ++ .../settings/component/AppearanceSection.kt | 19 ++- .../feature/settings/SettingsViewModel.kt | 6 + .../component/ContrastPickerDialog.kt | 58 +++++++++ .../feature/settings/SettingsViewModelTest.kt | 3 + .../feature/settings/DesktopSettingsScreen.kt | 18 +++ 19 files changed, 328 insertions(+), 65 deletions(-) create mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index d86df9d60..8316ad8e2 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -124,6 +124,8 @@ class MainActivity : ComponentActivity() { setSingletonImageLoaderFactory { get() } val theme by model.theme.collectAsStateWithLifecycle() + val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle() + val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) val dynamic = theme == MODE_DYNAMIC val dark = when (theme) { @@ -141,7 +143,7 @@ class MainActivity : ComponentActivity() { } AppCompositionLocals { - AppTheme(dynamicColor = dynamic, darkTheme = dark) { + AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) { val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle() // Signal to the system that the initial UI is "fully drawn" diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt new file mode 100644 index 000000000..fa708d165 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.domain.usecase.settings + +import org.koin.core.annotation.Single +import org.meshtastic.core.repository.UiPrefs + +@Single +open class SetContrastLevelUseCase constructor(private val uiPrefs: UiPrefs) { + operator fun invoke(value: Int) { + uiPrefs.setContrastLevel(value) + } +} diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index 33f688389..7fe0da822 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -62,6 +62,13 @@ class UiPrefsImpl( scope.launch { dataStore.edit { it[KEY_THEME] = value } } } + override val contrastLevel: StateFlow = + dataStore.data.map { it[KEY_CONTRAST_LEVEL] ?: 0 }.stateIn(scope, SharingStarted.Lazily, 0) + + override fun setContrastLevel(value: Int) { + scope.launch { dataStore.edit { it[KEY_CONTRAST_LEVEL] = value } } + } + override val locale: StateFlow = dataStore.data.map { it[KEY_LOCALE] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "") @@ -152,6 +159,7 @@ class UiPrefsImpl( val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed") val KEY_THEME = intPreferencesKey("theme") + val KEY_CONTRAST_LEVEL = intPreferencesKey("contrast-level") val KEY_LOCALE = stringPreferencesKey("locale") val KEY_NODE_SORT = intPreferencesKey("node-sort-option") val KEY_INCLUDE_UNKNOWN = booleanPreferencesKey("include-unknown") diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index f5203e3c1..bb32c1fbd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -80,6 +80,10 @@ interface UiPrefs { fun setTheme(value: Int) + val contrastLevel: StateFlow + + fun setContrastLevel(value: Int) + val locale: StateFlow fun setLocale(languageTag: String) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 9678c9919..77c923d94 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -278,10 +278,15 @@ Reset to defaults Apply Theme + Contrast Light Dark System default Choose theme + Contrast level + Standard + Medium + High Provide phone location to mesh Compact encoding for Cyrillic diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 2b9f9918f..9a703004c 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -84,6 +84,12 @@ class FakeUiPrefs : UiPrefs { theme.value = value } + override val contrastLevel = MutableStateFlow(0) + + override fun setContrastLevel(value: Int) { + contrastLevel.value = value + } + override val locale = MutableStateFlow("en") override fun setLocale(languageTag: String) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt new file mode 100644 index 000000000..cd68cd12c --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Application-wide contrast level for accessibility. + * + * [STANDARD] keeps the default Material 3 color scheme. [MEDIUM] uses Material 3 medium-contrast color tokens and + * increases message bubble opacity. [HIGH] uses Material 3 high-contrast color tokens, forces `onSurface` text in + * message bubbles, and replaces translucent node-color fills with opaque theme surfaces plus accent borders. + */ +enum class ContrastLevel(val value: Int) { + STANDARD(0), + MEDIUM(1), + HIGH(2), + ; + + companion object { + fun fromValue(value: Int): ContrastLevel = entries.firstOrNull { it.value == value } ?: STANDARD + } +} + +/** + * Composition local providing the current [ContrastLevel]. + * + * Read by components that need to adapt their rendering for accessibility (e.g. message bubbles, signal indicators). + */ +val LocalContrastLevel = staticCompositionLocalOf { ContrastLevel.STANDARD } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt index eb40222af..07c6ab3ad 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("UnusedPrivateProperty") +@file:Suppress("MatchingDeclarationName") package org.meshtastic.core.ui.theme @@ -25,6 +25,7 @@ import androidx.compose.material3.MotionScheme.Companion.expressive import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color @@ -272,19 +273,33 @@ val unspecified_scheme = ColorFamily(Color.Unspecified, Color.Unspecified, Color fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + contrastLevel: ContrastLevel = ContrastLevel.STANDARD, content: @Composable() () -> Unit, ) { - val dynamicScheme = if (dynamicColor) dynamicColorScheme(darkTheme) else null - val colorScheme = dynamicScheme ?: if (darkTheme) darkScheme else lightScheme + val dynamicScheme = + if (dynamicColor && contrastLevel == ContrastLevel.STANDARD) { + dynamicColorScheme(darkTheme) + } else { + null + } + val colorScheme = + dynamicScheme + ?: when (contrastLevel) { + ContrastLevel.MEDIUM -> if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme + ContrastLevel.HIGH -> if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme + else -> if (darkTheme) darkScheme else lightScheme + } - MaterialExpressiveTheme( - colorScheme = colorScheme, - typography = AppTypography, - motionScheme = expressive(), - content = content, - ) + CompositionLocalProvider(LocalContrastLevel provides contrastLevel) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = AppTypography, + motionScheme = expressive(), + content = content, + ) + } } const val MODE_DYNAMIC = 6969420 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index b1c4cebf2..12f1ea0f5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -118,6 +118,7 @@ class UIViewModel( } val theme: StateFlow = uiPrefs.theme + val contrastLevel: StateFlow = uiPrefs.contrastLevel val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 8b33a3612..11111dd7a 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -169,7 +169,8 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { val uiPrefs = koinInject() val themePref by uiPrefs.theme.collectAsState(initial = -1) val localePref by uiPrefs.locale.collectAsState(initial = "") - + val contrastLevelValue by uiPrefs.contrastLevel.collectAsState(initial = 0) + val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale) val isDarkTheme = @@ -179,7 +180,7 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { else -> isSystemInDarkTheme() } - MeshtasticDesktopApp(uiViewModel, isDarkTheme) + MeshtasticDesktopApp(uiViewModel, isDarkTheme, contrastLevel) } // ----- Application chrome (tray, window, navigation) ----- @@ -187,7 +188,11 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { /** Composes the system tray, window, and Coil image loader. */ @Composable @OptIn(ExperimentalCoilApi::class) -private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDarkTheme: Boolean) { +private fun ApplicationScope.MeshtasticDesktopApp( + uiViewModel: UIViewModel, + isDarkTheme: Boolean, + contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, +) { var isAppVisible by remember { mutableStateOf(true) } var isWindowReady by remember { mutableStateOf(false) } val trayState = rememberTrayState() @@ -219,7 +224,7 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa ) if (isWindowReady && isAppVisible) { - MeshtasticWindow(uiViewModel, isDarkTheme, appIcon, windowState) { isAppVisible = false } + MeshtasticWindow(uiViewModel, isDarkTheme, contrastLevel, appIcon, windowState) { isAppVisible = false } } } @@ -267,6 +272,7 @@ private fun WindowBoundsManager( private fun ApplicationScope.MeshtasticWindow( uiViewModel: UIViewModel, isDarkTheme: Boolean, + contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, appIcon: Painter, windowState: WindowState, onCloseRequest: () -> Unit, @@ -281,7 +287,9 @@ private fun ApplicationScope.MeshtasticWindow( onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) }, ) { CoilImageLoaderSetup() - AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } + AppTheme(darkTheme = isDarkTheme, contrastLevel = contrastLevel) { + DesktopMainScreen(uiViewModel, multiBackstack) + } } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index 380b913a5..c4c99720c 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus @@ -134,7 +133,7 @@ private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit, .clickable { onReact(emoji) }, contentAlignment = Alignment.Center, ) { - Text(text = emoji, fontSize = 20.sp) + Text(text = emoji, style = MaterialTheme.typography.titleMedium) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 586b91dd6..7d8747eb8 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -29,14 +29,12 @@ 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.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -47,8 +45,11 @@ 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.RectangleShape import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -72,6 +73,8 @@ import org.meshtastic.core.ui.emoji.EmojiPickerDialog import org.meshtastic.core.ui.icon.FormatQuote import org.meshtastic.core.ui.icon.HopCount import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.theme.ContrastLevel +import org.meshtastic.core.ui.theme.LocalContrastLevel import org.meshtastic.core.ui.theme.MessageItemColors import org.meshtastic.core.ui.util.createClipEntry @@ -175,7 +178,9 @@ fun MessageItem( } val containsBel = message.text.contains('\u0007') + val contrastLevel = LocalContrastLevel.current + val nodeColor = Color(if (message.fromLocal) ourNode.colors.second else node.colors.second) val alpha = if (message.filtered) { FILTERED_ALPHA @@ -184,15 +189,31 @@ fun MessageItem( } else { NORMAL_ALPHA } + val containerColor = - if (message.fromLocal) { - Color(ourNode.colors.second).copy(alpha = alpha) - } else { - Color(node.colors.second).copy(alpha = alpha) + when (contrastLevel) { + ContrastLevel.HIGH -> + when { + message.filtered -> MaterialTheme.colorScheme.surfaceContainerLow + inSelectionMode && selected -> MaterialTheme.colorScheme.surfaceContainerHighest + inSelectionMode && !selected -> MaterialTheme.colorScheme.surfaceContainerLow + else -> MaterialTheme.colorScheme.surfaceContainerHigh + } + ContrastLevel.MEDIUM -> nodeColor.copy(alpha = (alpha + 0.2f).coerceAtMost(1f)) + ContrastLevel.STANDARD -> nodeColor.copy(alpha = alpha) + } + val contentColor = + when (contrastLevel) { + ContrastLevel.HIGH, + ContrastLevel.MEDIUM, + -> MaterialTheme.colorScheme.onSurface + ContrastLevel.STANDARD -> Color(if (message.fromLocal) ourNode.colors.first else node.colors.first) + } + val metadataStyle = + when (contrastLevel) { + ContrastLevel.HIGH -> MaterialTheme.typography.bodySmall + else -> MaterialTheme.typography.labelSmall } - val cardColors = - CardDefaults.cardColors() - .copy(containerColor = containerColor, contentColor = contentColorFor(containerColor)) val messageShape = getMessageBubbleShape( cornerRadius = 8.dp, @@ -206,7 +227,12 @@ fun MessageItem( if (containsBel) { Modifier.border(2.dp, color = MessageItemColors.Red, shape = messageShape) } else { - Modifier + when (contrastLevel) { + ContrastLevel.HIGH -> Modifier.border(2.dp, color = nodeColor, shape = messageShape) + ContrastLevel.MEDIUM -> + Modifier.border(1.dp, color = nodeColor.copy(alpha = 0.6f), shape = messageShape) + ContrastLevel.STANDARD -> Modifier + } }, ) val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name @@ -244,9 +270,12 @@ fun MessageItem( onDoubleClick = onDoubleClick, ) .then(messageModifier) - .semantics(mergeDescendants = true) { contentDescription = messageA11yText }, + .semantics(mergeDescendants = true) { + contentDescription = messageA11yText + role = Role.Button + }, color = containerColor, - contentColor = contentColorFor(containerColor), + contentColor = contentColor, shape = messageShape, ) { Column(modifier = Modifier.width(IntrinsicSize.Max)) { @@ -254,16 +283,11 @@ fun MessageItem( modifier = Modifier.fillMaxWidth(), message = message, ourNode = ourNode, - hasSamePrev = hasSamePrev, onNavigateToOriginalMessage = onNavigateToOriginalMessage, ) Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { - AutoLinkText( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = cardColors.contentColor, - ) + AutoLinkText(text = message.text, style = MaterialTheme.typography.bodyMedium, color = contentColor) Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { if (!message.fromLocal) { @@ -281,7 +305,10 @@ fun MessageItem( imageVector = MeshtasticIcons.HopCount, contentDescription = null, modifier = Modifier.size(14.dp), - tint = cardColors.contentColor.copy(alpha = 0.7f), + tint = + contentColor.copy( + alpha = if (contrastLevel == ContrastLevel.HIGH) 1f else 0.7f, + ), ) Text( text = @@ -290,7 +317,7 @@ fun MessageItem( } else { "?" }, - style = MaterialTheme.typography.labelSmall, + style = metadataStyle, ) } } @@ -306,8 +333,13 @@ fun MessageItem( if (message.filtered) { Text( text = stringResource(Res.string.filter_message_label), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = metadataStyle, + color = + if (contrastLevel == ContrastLevel.HIGH) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, modifier = Modifier.padding(start = 8.dp, end = 4.dp), ) } @@ -318,11 +350,7 @@ fun MessageItem( ) } Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier.padding(start = 16.dp), - text = message.time, - style = MaterialTheme.typography.labelSmall, - ) + Text(modifier = Modifier.padding(start = 16.dp), text = message.time, style = metadataStyle) } } } @@ -356,30 +384,33 @@ private enum class ActiveSheet { private fun OriginalMessageSnippet( message: Message, ourNode: Node, - hasSamePrev: Boolean, onNavigateToOriginalMessage: (Int) -> Unit, modifier: Modifier = Modifier, ) { val originalMessage = message.originalMessage if (originalMessage != null && originalMessage.packetId != 0) { val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node - val cardColors = - CardDefaults.cardColors() - .copy( - containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.8f), - contentColor = Color(originalMessageNode.colors.first), - ) + val contrastLevel = LocalContrastLevel.current + val replyContainerColor = + when (contrastLevel) { + ContrastLevel.HIGH -> MaterialTheme.colorScheme.surfaceContainer + else -> Color(originalMessageNode.colors.second).copy(alpha = 0.8f) + } + val replyContentColor = + when (contrastLevel) { + ContrastLevel.HIGH, + ContrastLevel.MEDIUM, + -> MaterialTheme.colorScheme.onSurface + ContrastLevel.STANDARD -> Color(originalMessageNode.colors.first) + } + // Rectangle shape — the outer message bubble's Surface clips to its + // rounded corners, so the reply header inherits the correct top radii + // automatically and stays square on the bottom where body text follows. Surface( modifier = modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) }, - contentColor = cardColors.contentColor, - color = cardColors.containerColor, - shape = - getMessageBubbleShape( - cornerRadius = 16.dp, - isSender = originalMessage.fromLocal, - hasSamePrev = hasSamePrev, - hasSameNext = true, // always square off original message bottom - ), + contentColor = replyContentColor, + color = replyContainerColor, + shape = RectangleShape, ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 6545083bb..27797592b 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -123,7 +123,6 @@ internal fun ReactionItem( text = emojiCount.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, - fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -248,7 +247,13 @@ internal fun ReactionDialog( text = "$emoji${reactions.size}", modifier = Modifier.clip(CircleShape) - .background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent) + .background( + if (selectedEmoji == emoji) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + Color.Transparent + }, + ) .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier) .padding(8.dp) .clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji }, diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 82558309d..eeab3b873 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -56,6 +56,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.settings.component.AppInfoSection import org.meshtastic.feature.settings.component.AppearanceSection +import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.PersistenceSection import org.meshtastic.feature.settings.component.PrivacySection @@ -155,6 +156,14 @@ fun SettingsScreen( ) } + var showContrastPickerDialog by remember { mutableStateOf(false) } + if (showContrastPickerDialog) { + ContrastPickerDialog( + onClickContrast = { settingsViewModel.setContrastLevel(it) }, + onDismiss = { showContrastPickerDialog = false }, + ) + } + Scaffold( topBar = { MainAppBar( @@ -227,6 +236,7 @@ fun SettingsScreen( AppearanceSection( onShowLanguagePicker = { showLanguagePickerDialog = true }, onShowThemePicker = { showThemePickerDialog = true }, + onShowContrastPicker = { showContrastPickerDialog = true }, ) ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt index f70cda978..cb61c8295 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt @@ -28,6 +28,7 @@ import androidx.core.net.toUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_settings +import org.meshtastic.core.resources.contrast import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.theme import org.meshtastic.core.ui.component.ListItem @@ -37,9 +38,13 @@ import org.meshtastic.core.ui.icon.Language import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme -/** Section for app appearance settings like language and theme. */ +/** Section for app appearance settings like language, theme, and contrast. */ @Composable -fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) { +fun AppearanceSection( + onShowLanguagePicker: () -> Unit, + onShowThemePicker: () -> Unit, + onShowContrastPicker: () -> Unit, +) { val context = LocalContext.current val settingsLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {} @@ -74,11 +79,19 @@ fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> ) { onShowThemePicker() } + + ListItem( + text = stringResource(Res.string.contrast), + leadingIcon = MeshtasticIcons.FormatPaint, + trailingIcon = null, + ) { + onShowContrastPicker() + } } } @Preview(showBackground = true) @Composable private fun AppearanceSectionPreview() { - AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) } + AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}, onShowContrastPicker = {}) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index fc5923c1a..d4b39565b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -33,6 +33,7 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase +import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase @@ -65,6 +66,7 @@ class SettingsViewModel( private val meshLogPrefs: MeshLogPrefs, private val notificationPrefs: NotificationPrefs, private val setThemeUseCase: SetThemeUseCase, + private val setContrastLevelUseCase: SetContrastLevelUseCase, private val setLocaleUseCase: SetLocaleUseCase, private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, private val setProvideLocationUseCase: SetProvideLocationUseCase, @@ -162,6 +164,10 @@ class SettingsViewModel( setThemeUseCase(theme) } + fun setContrastLevel(level: Int) { + setContrastLevelUseCase(level) + } + /** Set the application locale. Empty string means system default. */ fun setLocale(languageTag: String) { setLocaleUseCase(languageTag) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt new file mode 100644 index 000000000..c8adc418a --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("MatchingDeclarationName") + +package org.meshtastic.feature.settings.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.choose_contrast +import org.meshtastic.core.resources.contrast_high +import org.meshtastic.core.resources.contrast_medium +import org.meshtastic.core.resources.contrast_standard +import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.theme.ContrastLevel + +/** Contrast level options matching [ContrastLevel] ordinal values. */ +enum class ContrastOption(val label: StringResource, val level: ContrastLevel) { + STANDARD(label = Res.string.contrast_standard, level = ContrastLevel.STANDARD), + MEDIUM(label = Res.string.contrast_medium, level = ContrastLevel.MEDIUM), + HIGH(label = Res.string.contrast_high, level = ContrastLevel.HIGH), +} + +/** Shared dialog for picking a contrast level. Used by both Android and Desktop settings screens. */ +@Composable +fun ContrastPickerDialog(onClickContrast: (Int) -> Unit, onDismiss: () -> Unit) { + MeshtasticDialog( + title = stringResource(Res.string.choose_contrast), + onDismiss = onDismiss, + text = { + Column { + ContrastOption.entries.forEach { option -> + ListItem(text = stringResource(option.label), trailingIcon = null) { + onClickContrast(option.level.value) + onDismiss() + } + } + } + }, + ) +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 64eab2f80..0ba5c3a79 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase +import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase @@ -96,6 +97,7 @@ class SettingsViewModelTest { val uiPrefs = appPreferences.ui val setThemeUseCase = SetThemeUseCase(uiPrefs) + val setContrastLevelUseCase = SetContrastLevelUseCase(uiPrefs) val setLocaleUseCase = SetLocaleUseCase(uiPrefs) val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs) val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs) @@ -116,6 +118,7 @@ class SettingsViewModelTest { meshLogPrefs = appPreferences.meshLog, notificationPrefs = notificationPrefs, setThemeUseCase = setThemeUseCase, + setContrastLevelUseCase = setContrastLevelUseCase, setLocaleUseCase = setLocaleUseCase, setAppIntroCompletedUseCase = setAppIntroCompletedUseCase, setProvideLocationUseCase = setProvideLocationUseCase, diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 9a221f8dd..2e358a58c 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -46,6 +46,7 @@ import org.meshtastic.core.resources.acknowledgements import org.meshtastic.core.resources.app_settings import org.meshtastic.core.resources.app_version import org.meshtastic.core.resources.bottom_nav_settings +import org.meshtastic.core.resources.contrast import org.meshtastic.core.resources.device_db_cache_limit import org.meshtastic.core.resources.device_db_cache_limit_summary import org.meshtastic.core.resources.info @@ -67,6 +68,7 @@ import org.meshtastic.core.ui.icon.Memory import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.core.ui.util.rememberShowToastResource +import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.NotificationSection @@ -101,6 +103,7 @@ fun DesktopSettingsScreen( var showThemePickerDialog by remember { mutableStateOf(false) } var showLanguagePickerDialog by remember { mutableStateOf(false) } + var showContrastPickerDialog by remember { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -108,6 +111,13 @@ fun DesktopSettingsScreen( ) } + if (showContrastPickerDialog) { + ContrastPickerDialog( + onClickContrast = { settingsViewModel.setContrastLevel(it) }, + onDismiss = { showContrastPickerDialog = false }, + ) + } + if (showLanguagePickerDialog) { LanguagePickerDialog( onSelectLanguage = { tag -> settingsViewModel.setLocale(tag) }, @@ -172,6 +182,14 @@ fun DesktopSettingsScreen( showThemePickerDialog = true } + ListItem( + text = stringResource(Res.string.contrast), + leadingIcon = MeshtasticIcons.FormatPaint, + trailingIcon = null, + ) { + showContrastPickerDialog = true + } + ListItem( text = stringResource(Res.string.preferences_language), leadingIcon = MeshtasticIcons.Language, From bf0deef7089000162d0bb61d8f29c0ce58827bf2 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:14:31 -0500 Subject: [PATCH 063/114] fix(icons): audit and correct icon migration regressions from #5030 #5040 #5056 (#5136) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/core/ui/icon/Device.kt | 3 +- .../messaging/component/MessageStatusIcon.kt | 10 +-- .../feature/node/component/NodeItem.kt | 5 ++ .../feature/node/component/NodeStatusIcons.kt | 61 ++----------------- .../feature/node/list/NodeListScreen.kt | 2 + .../feature/node/list/NodeListViewModel.kt | 9 +++ .../node/list/NodeListViewModelTest.kt | 4 ++ 7 files changed, 34 insertions(+), 60 deletions(-) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt index 66060116f..6bf669ab6 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt @@ -25,6 +25,7 @@ import org.meshtastic.core.resources.ic_fingerprint import org.meshtastic.core.resources.ic_fork_left import org.meshtastic.core.resources.ic_home import org.meshtastic.core.resources.ic_icecream +import org.meshtastic.core.resources.ic_memory import org.meshtastic.core.resources.ic_military_tech import org.meshtastic.core.resources.ic_mountain_flag import org.meshtastic.core.resources.ic_my_location @@ -75,4 +76,4 @@ val MeshtasticIcons.DeviceNumbers: ImageVector val MeshtasticIcons.Android: ImageVector @Composable get() = vectorResource(Res.drawable.ic_android) val MeshtasticIcons.HardwareModel: ImageVector - @Composable get() = vectorResource(Res.drawable.ic_router) + @Composable get() = vectorResource(Res.drawable.ic_memory) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt index 501a3f7dc..7b361d497 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt @@ -24,11 +24,13 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.message_delivery_status import org.meshtastic.core.ui.icon.Acknowledged +import org.meshtastic.core.ui.icon.AddLink +import org.meshtastic.core.ui.icon.CloudUpload +import org.meshtastic.core.ui.icon.LinkIcon import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MessageEnroute import org.meshtastic.core.ui.icon.MessageError import org.meshtastic.core.ui.icon.MqttDelivered -import org.meshtastic.core.ui.icon.MqttSyncing import org.meshtastic.core.ui.icon.Warning @Composable @@ -36,10 +38,10 @@ fun MessageStatusIcon(status: MessageStatus, modifier: Modifier = Modifier) { val icon = when (status) { MessageStatus.RECEIVED -> MeshtasticIcons.Acknowledged - MessageStatus.QUEUED -> MeshtasticIcons.MqttSyncing + MessageStatus.QUEUED -> MeshtasticIcons.CloudUpload MessageStatus.DELIVERED -> MeshtasticIcons.MqttDelivered - MessageStatus.SFPP_ROUTING -> MeshtasticIcons.MqttSyncing - MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.MqttDelivered + MessageStatus.SFPP_ROUTING -> MeshtasticIcons.AddLink + MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.LinkIcon MessageStatus.ENROUTE -> MeshtasticIcons.MessageEnroute MessageStatus.ERROR -> MeshtasticIcons.MessageError else -> MeshtasticIcons.Warning diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 514be15e7..ad6714db7 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -48,6 +48,7 @@ import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.isUnmessageableRole import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit @@ -106,6 +107,7 @@ fun NodeItem( onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, connectionState: ConnectionState, + deviceType: DeviceType? = null, isActive: Boolean = false, ) { val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } @@ -166,6 +168,7 @@ fun NodeItem( isMuted = isMuted, isUnmessageable = unmessageable, connectionState = connectionState, + deviceType = deviceType, contentColor = contentColor, ) @@ -400,6 +403,7 @@ private fun NodeItemHeader( isMuted: Boolean, isUnmessageable: Boolean, connectionState: ConnectionState, + deviceType: DeviceType?, contentColor: Color, ) { Row( @@ -445,6 +449,7 @@ private fun NodeItemHeader( isMuted = isMuted, isUnmessageable = isUnmessageable, connectionState = connectionState, + deviceType = deviceType, contentColor = contentColor, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt index 007c12c96..1bbafad6a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connecting @@ -46,17 +47,11 @@ import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.mute_always import org.meshtastic.core.resources.unmessageable import org.meshtastic.core.resources.unmonitored_or_infrastructure -import org.meshtastic.core.ui.icon.DeviceSleep -import org.meshtastic.core.ui.icon.Disconnected +import org.meshtastic.core.ui.component.ConnectionsNavIcon import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.MqttDelivered -import org.meshtastic.core.ui.icon.MqttSyncing import org.meshtastic.core.ui.icon.Unmessageable import org.meshtastic.core.ui.icon.VolumeOff -import org.meshtastic.core.ui.theme.StatusColors.StatusGreen -import org.meshtastic.core.ui.theme.StatusColors.StatusOrange -import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @OptIn(ExperimentalMaterial3Api::class) @@ -68,11 +63,12 @@ fun NodeStatusIcons( isMuted: Boolean, connectionState: ConnectionState, modifier: Modifier = Modifier, + deviceType: DeviceType? = null, contentColor: Color = LocalContentColor.current, ) { Row(modifier = modifier.padding(4.dp)) { if (isThisNode) { - ThisNodeStatusBadge(connectionState) + ThisNodeStatusBadge(connectionState = connectionState, deviceType = deviceType) } if (isUnmessageable) { @@ -104,7 +100,7 @@ fun NodeStatusIcons( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ThisNodeStatusBadge(connectionState: ConnectionState) { +private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: DeviceType?) { TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { @@ -123,55 +119,10 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState) { }, state = rememberTooltipState(), ) { - when (connectionState) { - ConnectionState.Connected -> ConnectedStatusIcon() - ConnectionState.Connecting -> ConnectingStatusIcon() - ConnectionState.Disconnected -> DisconnectedStatusIcon() - ConnectionState.DeviceSleep -> DeviceSleepStatusIcon() - } + ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType, modifier = Modifier.size(24.dp)) } } -@Composable -private fun ConnectedStatusIcon() { - Icon( - imageVector = MeshtasticIcons.MqttDelivered, - contentDescription = stringResource(Res.string.connected), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusGreen, - ) -} - -@Composable -private fun ConnectingStatusIcon() { - Icon( - imageVector = MeshtasticIcons.MqttSyncing, - contentDescription = stringResource(Res.string.connecting), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusOrange, - ) -} - -@Composable -private fun DisconnectedStatusIcon() { - Icon( - imageVector = MeshtasticIcons.Disconnected, - contentDescription = stringResource(Res.string.disconnected), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusRed, - ) -} - -@Composable -private fun DeviceSleepStatusIcon() { - Icon( - imageVector = MeshtasticIcons.DeviceSleep, - contentDescription = stringResource(Res.string.device_sleeping), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusYellow, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StatusBadge( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 9c2c208f4..5a156b836 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -97,6 +97,7 @@ fun NodeListScreen( } val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val deviceType by viewModel.deviceType.collectAsStateWithLifecycle() val isScrollInProgress by remember { derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) } @@ -187,6 +188,7 @@ fun NodeListScreen( onClick = { navigateToNodeDetails(node.num) }, onLongClick = longClick, connectionState = connectionState, + deviceType = deviceType, isActive = isActive, ) val isThisNode = remember(node) { ourNode?.num == node.num } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index df65a3477..172a296eb 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -23,13 +23,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeManagementActions @@ -45,6 +48,7 @@ class NodeListViewModel( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val radioController: RadioController, + private val radioInterfaceService: RadioInterfaceService, val nodeManagementActions: NodeManagementActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, val nodeFilterPreferences: NodeFilterPreferences, @@ -58,6 +62,11 @@ class NodeListViewModel( val connectionState = serviceRepository.connectionState + val deviceType: StateFlow = + radioInterfaceService.currentDeviceAddressFlow + .map { address -> address?.let { DeviceType.fromAddress(it) } } + .stateInWhileSubscribed(initialValue = null) + private val nodeSortOption = nodeFilterPreferences.nodeSortOption private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "") diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt index 602134aa0..9511a2da1 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt @@ -32,6 +32,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.core.testing.FakeRadioInterfaceService import org.meshtastic.core.testing.TestDataFactory import org.meshtastic.feature.node.detail.NodeManagementActions import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase @@ -45,6 +46,7 @@ class NodeListViewModelTest { private lateinit var viewModel: NodeListViewModel private lateinit var nodeRepository: FakeNodeRepository private lateinit var radioController: FakeRadioController + private lateinit var radioInterfaceService: FakeRadioInterfaceService private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) private val nodeFilterPreferences: NodeFilterPreferences = mock(MockMode.autofill) @@ -55,6 +57,7 @@ class NodeListViewModelTest { fun setUp() { nodeRepository = FakeNodeRepository() radioController = FakeRadioController() + radioInterfaceService = FakeRadioInterfaceService() every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(org.meshtastic.proto.DeviceProfile()) @@ -79,6 +82,7 @@ class NodeListViewModelTest { radioConfigRepository = radioConfigRepository, serviceRepository = serviceRepository, radioController = radioController, + radioInterfaceService = radioInterfaceService, nodeManagementActions = nodeManagementActions, getFilteredNodesUseCase = getFilteredNodesUseCase, nodeFilterPreferences = nodeFilterPreferences, From 79ed0a865a1d2b7fe9218a8ac3aad25711015316 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:14:38 -0500 Subject: [PATCH 064/114] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5128) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- app/src/main/assets/firmware_releases.json | 14 +++++++------- .../composeResources/values-et/strings.xml | 3 +++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json index 4859e45cf..ffdb465d6 100644 --- a/app/src/main/assets/firmware_releases.json +++ b/app/src/main/assets/firmware_releases.json @@ -24,6 +24,13 @@ } ], "alpha": [ + { + "id": "v2.7.22.96dd647", + "title": "Meshtastic Firmware 2.7.22.96dd647 Alpha", + "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.22.96dd647", + "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.22.96dd647/firmware-2.7.22.96dd647.json", + "release_notes": "## 🐛 Bug fixes and maintenance\r\n\r\n- Fix(native): implement BinarySemaphorePosix with proper pthread synchronization by @iannucci in https://github.com/meshtastic/firmware/pull/9895\r\n- Meshtasticd: Add configs for ebyte-ecb41-pge (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10086\r\n- Meshtasticd: Add configs for forlinx-ok3506-s12 (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10087\r\n- Fix Linux Input enable logic by @jp-bennett in https://github.com/meshtastic/firmware/pull/10093\r\n- PPA: Use SFTP method for uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/10138\r\n- Switch PlatformIO deps from PIO Registry to tagged GitHub zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/10142\r\n- Fix display method to use const qualifier for previousBuffer pointer by @vidplace7 in https://github.com/meshtastic/firmware/pull/10146\r\n- Fix last cppcheck issue by @caveman99 in https://github.com/meshtastic/firmware/pull/10154\r\n- Fix heap blowout on TBeams by @thebentern in https://github.com/meshtastic/firmware/pull/10155\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update meshtastic-esp32_https_server digest to 0c71f38 by @app/renovate in https://github.com/meshtastic/firmware/pull/10081\r\n- Update meshtastic-st7789 digest to 222554e by @app/renovate in https://github.com/meshtastic/firmware/pull/10121\r\n- Update actions/github-script action to v9 by @app/renovate in https://github.com/meshtastic/firmware/pull/10122\r\n- Update meshtastic-st7789 digest to 7228c49 by @app/renovate in https://github.com/meshtastic/firmware/pull/10131\r\n- Update pnpm/action-setup action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10132\r\n- Update meshtastic-st7789 digest to 4d957e7 by @app/renovate in https://github.com/meshtastic/firmware/pull/10134\r\n- Update meshtastic-st7789 digest to a787bee by @app/renovate in https://github.com/meshtastic/firmware/pull/10147\r\n- Update softprops/action-gh-release action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/10150\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.21.1370b23...v2.7.22.96dd647" + }, { "id": "v2.7.21.1370b23", "title": "Meshtastic Firmware 2.7.21.1370b23 Alpha", @@ -177,13 +184,6 @@ "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.8.ef9d0d7", "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.8.ef9d0d7/firmware-esp32-2.6.8.ef9d0d7.zip", "release_notes": "## 🚀 Enhancements\r\n* 20948 compass support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6707\r\n* Update XIAO_NRF_KIT RXEN Pin definition by @NomDeTom in https://github.com/meshtastic/firmware/pull/6717\r\n* Add client notification before role based power saving (sleep) by @thebentern in https://github.com/meshtastic/firmware/pull/6759\r\n* Actions: Fix end to end tests by @vidplace7 in https://github.com/meshtastic/firmware/pull/6776\r\n* Add clarifying note about AHT20 also being included with AHT10 library by @NomDeTom in https://github.com/meshtastic/firmware/pull/6787\r\n* Only send nodes on want_config of 69421 by @thebentern in https://github.com/meshtastic/firmware/pull/6792\r\n* Add contact admin message (for QR code) by @thebentern in https://github.com/meshtastic/firmware/pull/6806\r\n* Crowpanel 4.3, 5.0, 7.0 support by @caveman99 in https://github.com/meshtastic/firmware/pull/6611\r\n* MQTT userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6802\r\n* Unmessagable implementation and defaults by @thebentern in https://github.com/meshtastic/firmware/pull/6811\r\n* Added new map report opt-in for compliance and limit map report (and default) to one hour by @thebentern in https://github.com/meshtastic/firmware/pull/6813\r\n* chore(deps): update meshtastic/device-ui digest to 35576e1 by @renovate in https://github.com/meshtastic/firmware/pull/6747\r\n\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Renovate: fix device-ui match (tiny fix) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6748\r\n* Add some no-nonsense coercion for self-reporting node values by @thebentern in https://github.com/meshtastic/firmware/pull/6793\r\n* Device-install.sh: detect t-eth-elite as s3 device by @chri2 in https://github.com/meshtastic/firmware/pull/6767\r\n* Fixes BUG #6243 by @Richard3366 in https://github.com/meshtastic/firmware/pull/6781\r\n* Update Seeed Solar Node by @rcarteraz in https://github.com/meshtastic/firmware/pull/6763\r\n* Protect T-Echo's touch button against phantom presses in OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6735\r\n* Don't run `test-native` for event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6749\r\n* Fix EVENT_MODE on mqttless targets by @vidplace7 in https://github.com/meshtastic/firmware/pull/6750\r\n* Fix event templates (names, PSKs) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6753\r\n* Add suppport for Quectel L80 by @fifieldt in https://github.com/meshtastic/firmware/pull/6803\r\n\r\n## New Contributors\r\n* @chri2 made their first contribution in https://github.com/meshtastic/firmware/pull/6767\r\n* @Richard3366 made their first contribution in https://github.com/meshtastic/firmware/pull/6781\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.7.2d6181f...v2.6.8.ef9d0d7" - }, - { - "id": "v2.6.7.2d6181f", - "title": "Meshtastic Firmware 2.6.7.2d6181f Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.7.2d6181f", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.7.2d6181f/firmware-esp32-2.6.7.2d6181f.zip", - "release_notes": "## 🚀 Enhancements\r\n* Step one of Linux Sensor support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6673\r\n* PMSA003I: add support for driving SET pin low while not actively taking a telemetry reading by @vogon in https://github.com/meshtastic/firmware/pull/6569\r\n* UDP-multicast: bump platform-native to fix UDP read of unitialized memory bug by @Jorropo in https://github.com/meshtastic/firmware/pull/6686\r\n* UDP-multicast: remove the thread from the multicast thread API by @Jorropo in https://github.com/meshtastic/firmware/pull/6685\r\n* Rate limit waypoints and alerts and increase to allow every 10 seconds instead of 5 by @thebentern in https://github.com/meshtastic/firmware/pull/6699\r\n* Restore InkHUD to defaults on factory reset by @todd-herbert in https://github.com/meshtastic/firmware/pull/6637\r\n* MUI: native frame buffer support by @mverch67 in https://github.com/meshtastic/firmware/pull/6703\r\n* Add PA1010D GPS support by @fmckeogh in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Fix: native runs 100% CPU in tft_task_handler() when deviceScreen is null by @jp-bennett in https://github.com/meshtastic/firmware/pull/6695\r\n* Lock SPI bus while in use by InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/6719\r\n* Update template for event userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6720\r\n* Renovate: Add changelogs for device-ui, cleanup by @vidplace7 in https://github.com/meshtastic/firmware/pull/6733\r\n* Update Bosch BSEC2 to v1.8.2610, BME68x to v1.2.40408 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6727\r\n\r\n## New Contributors\r\n* @vogon made their first contribution in https://github.com/meshtastic/firmware/pull/6569\r\n* @fmckeogh made their first contribution in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.6.54c1423...v2.6.7.2d6181f" } ] }, diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 4b8e5a879..6f5a7fa4d 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -582,6 +582,9 @@ Väljundi kestvus (millisekundit) Häire ajalõpp (sekundit) Helin + Imporditud helin + Fail on tühi + Viga importimisel: %1$s Mängi ette Kasuta I2S summerina LoRa From 50ade01e554f433fcc15c760e88cd7ae46762f16 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:49:34 -0500 Subject: [PATCH 065/114] docs(agents): add PR and commit hygiene guidance (#5137) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9fcc166b5..ab2549475 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,3 +66,9 @@ Do NOT duplicate content into agent-specific files. When you modify architecture - **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules. - **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change. + + +- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation. +- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style. +- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters. + From 72b981f73b78c8d5e2e62349dc10a50f32eca13c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:17:50 -0500 Subject: [PATCH 066/114] =?UTF-8?q?chore:=20KMP=20audit=20=E2=80=94=20comm?= =?UTF-8?q?onize=20code,=20centralize=20utilities,=20eliminate=20dead=20ab?= =?UTF-8?q?stractions=20(#5133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../instructions/kmp-common.instructions.md | 3 + .github/workflows/reusable-check.yml | 2 +- .skills/code-review/SKILL.md | 9 +- .skills/compose-ui/SKILL.md | 32 +- .skills/implement-feature/SKILL.md | 2 +- .skills/kmp-architecture/SKILL.md | 8 +- .skills/navigation-and-di/SKILL.md | 7 + .skills/project-overview/SKILL.md | 5 + .skills/testing-ci/SKILL.md | 11 +- AGENTS.md | 14 +- .../kotlin/org/meshtastic/app/MainActivity.kt | 4 +- .../org/meshtastic/app/MeshUtilApplication.kt | 3 +- .../org/meshtastic/app/di/NetworkModule.kt | 14 +- .../main/kotlin/KmpFeatureConventionPlugin.kt | 6 +- .../org/meshtastic/core/ble/BleRetry.kt | 4 +- core/common/README.md | 4 +- core/common/build.gradle.kts | 1 + .../core/common/util/CommonUri.android.kt | 45 - .../core/common/util/MeshtasticUriExt.kt | 25 - .../org/meshtastic/core/common/ByteUtils.kt | 25 - .../{MeshtasticUri.kt => AddressUtils.kt} | 17 +- .../meshtastic/core/common/util/CommonUri.kt | 28 +- .../meshtastic/core/common/util/Exceptions.kt | 28 +- .../meshtastic/core/common/util/Formatter.kt | 113 +- .../HomoglyphCharacterStringTransformer.kt | 6 +- .../core/common/util/MetricFormatter.kt | 53 + .../core/common/util/AddressUtilsTest.kt | 72 ++ ...{MeshtasticUriTest.kt => CommonUriTest.kt} | 18 +- .../core/common/util/FormatStringTest.kt | 44 + .../core/common/util/MetricFormatterTest.kt | 123 ++ .../meshtastic/core/common/util/Formatter.kt | 130 -- .../meshtastic/core/common/util/NoopStubs.kt | 14 - .../meshtastic/core/common/util/Formatter.kt | 20 - .../core/common/util/CommonUri.jvm.kt | 49 - .../core/common/util/JvmPlatformUtils.kt | 20 +- .../core/common/util/CommonUriTest.kt | 44 - .../core/data/manager/HistoryManagerImpl.kt | 3 +- .../data/manager/MeshActionHandlerImpl.kt | 3 +- .../data/manager/MeshConnectionManagerImpl.kt | 2 +- .../data/manager/MeshMessageProcessorImpl.kt | 2 +- .../DeviceHardwareRepositoryImpl.kt | 5 +- .../FirmwareReleaseRepositoryImpl.kt | 5 +- .../data/repository/PacketRepositoryImpl.kt | 2 +- .../38.json | 1052 +++++++++++++++++ .../core/database/DatabaseConstants.kt | 12 +- .../core/database/DatabaseManager.kt | 2 + .../core/database/MeshtasticDatabase.kt | 3 +- .../core/database/dao/MeshLogDao.kt | 6 +- .../core/database/dao/NodeInfoDao.kt | 77 +- .../meshtastic/core/database/dao/PacketDao.kt | 51 +- .../core/database/entity/NodeEntity.kt | 1 + .../meshtastic/core/database/entity/Packet.kt | 12 +- .../core/model/util/AndroidDateTimeUtils.kt | 51 - .../meshtastic/core/model/util/UriBridge.kt | 3 +- .../kotlin/org/meshtastic/core/model/Node.kt | 32 +- .../meshtastic/core/model/util/Extensions.kt | 2 +- .../meshtastic/core/model/util/SfppHasher.kt | 24 +- .../core/model/util/SharedContact.kt | 2 +- .../core/model/util/CommonUtilsTest.kt} | 4 +- .../core/model/util/SfppHasherTest.kt | 87 ++ .../meshtastic/core/model/util/NoopStubs.kt | 4 - .../meshtastic/core/model/util/SfppHasher.kt | 35 - .../core/network/HttpClientDefaults.kt | 3 + .../core/network/radio/MockRadioTransport.kt | 4 +- .../core/network/service/ApiService.kt | 8 +- .../network/repository/JvmServiceDiscovery.kt | 6 +- .../repository/JvmServiceDiscoveryTest.kt | 9 +- .../core/prefs/mesh/MeshPrefsImpl.kt | 10 +- .../meshtastic/core/repository/FileService.kt | 6 +- .../core/repository/PacketRepository.kt | 2 +- .../composeResources/values/strings.xml | 6 +- .../core/service/AndroidFileServiceTest.kt | 9 +- .../core/service/AndroidFileService.kt | 17 +- .../meshtastic/core/service/JvmFileService.kt | 20 +- .../org/meshtastic/core/takserver/CoTXml.kt | 32 +- .../core/takserver/fountain/CodecExpect.kt | 8 +- .../fountain/{CodecActual.kt => ZlibCodec.kt} | 19 - .../fountain/{CodecActual.kt => ZlibCodec.kt} | 8 - .../meshtastic/core/ui/util/PlatformUtils.kt | 14 +- .../core/ui/component/DropDownPreference.kt | 13 +- .../ui/component/EditPasswordPreference.kt | 4 +- .../meshtastic/core/ui/component/ImportFab.kt | 9 +- .../core/ui/component/LoraSignalIndicator.kt | 6 +- .../core/ui/component/MaterialBatteryInfo.kt | 7 +- .../core/ui/component/SignalInfo.kt | 7 +- .../core/ui/emoji/EmojiPickerDialog.kt | 7 +- .../core/ui/qr/ScannedQrCodeDialog.kt | 3 +- .../meshtastic/core/ui/util/PlatformUtils.kt | 3 +- .../core/ui/viewmodel/UIViewModel.kt | 9 +- .../core/ui/viewmodel/ViewModelExtensions.kt | 2 +- .../org/meshtastic/core/ui/util/NoopStubs.kt | 3 +- .../meshtastic/core/ui/util/PlatformUtils.kt | 7 +- .../kotlin/org/meshtastic/desktop/Main.kt | 14 +- .../desktop/di/DesktopKoinModule.kt | 9 +- docs/kmp-status.md | 8 +- docs/roadmap.md | 4 +- .../ui/components/CurrentlyConnectedInfo.kt | 7 +- .../firmware/AndroidFirmwareFileHandler.kt | 23 +- .../feature/firmware/FirmwareUpdateScreen.kt | 4 +- .../firmware/FirmwareUpdateViewModel.kt | 15 +- .../feature/firmware/ota/BleOtaTransport.kt | 9 +- .../feature/firmware/ota/BleScanSupport.kt | 2 +- .../feature/firmware/ota/WifiOtaTransport.kt | 7 +- .../firmware/ota/dfu/SecureDfuTransport.kt | 20 +- .../feature/messaging/QuickChatPreviews.kt | 0 .../component/MessageItemPreviews.kt | 0 .../messaging/component/ReactionPreviews.kt | 0 .../ui/contact/AdaptiveContactsScreen.kt | 4 +- .../feature/messaging/ui/contact/Contacts.kt | 11 +- .../node/component/NodeDetailsSection.kt | 6 +- .../feature/node/component/NodeItem.kt | 32 +- .../feature/node/list/NodeListScreen.kt | 4 +- .../feature/node/metrics/DeviceMetrics.kt | 21 +- .../feature/node/metrics/MetricsViewModel.kt | 17 +- .../feature/node/metrics/PowerMetrics.kt | 17 +- .../feature/node/metrics/SignalMetrics.kt | 20 +- .../feature/node/metrics/TracerouteLog.kt | 7 +- .../node/navigation/AdaptiveNodeListScreen.kt | 2 +- .../node/navigation/NodesNavigation.kt | 4 +- .../node/metrics/MetricsViewModelTest.kt | 4 +- .../feature/settings/SettingsScreen.kt | 15 +- .../component/SecurityConfigScreen.android.kt | 4 +- .../feature/settings/SettingsViewModel.kt | 4 +- .../feature/settings/debugging/DebugSearch.kt | 4 +- .../settings/debugging/DebugViewModel.kt | 23 +- .../settings/navigation/SettingsNavigation.kt | 3 +- .../settings/radio/RadioConfigViewModel.kt | 8 +- .../radio/channel/ChannelConfigScreen.kt | 8 +- .../settings/radio/channel/ChannelScreen.kt | 4 +- .../radio/component/LoRaConfigItemList.kt | 2 +- .../wifiprovision/domain/NymeaWifiService.kt | 7 +- gradle/libs.versions.toml | 3 +- 132 files changed, 2186 insertions(+), 916 deletions(-) delete mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt delete mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt delete mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt rename core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/{MeshtasticUri.kt => AddressUtils.kt} (62%) create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt create mode 100644 core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt rename core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/{MeshtasticUriTest.kt => CommonUriTest.kt} (65%) create mode 100644 core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt delete mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt delete mode 100644 core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt delete mode 100644 core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt delete mode 100644 core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json delete mode 100644 core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt rename core/{common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt => model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt} (95%) create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt delete mode 100644 core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt rename core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/{CodecActual.kt => ZlibCodec.kt} (83%) rename core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/{CodecActual.kt => ZlibCodec.kt} (90%) rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt (100%) rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt (100%) rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt (100%) diff --git a/.github/instructions/kmp-common.instructions.md b/.github/instructions/kmp-common.instructions.md index 235d5826d..7dac915bc 100644 --- a/.github/instructions/kmp-common.instructions.md +++ b/.github/instructions/kmp-common.instructions.md @@ -14,4 +14,7 @@ applyTo: "**/commonMain/**/*.kt" - Never use plain `androidx.compose` dependencies in `commonMain`. - Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings. - CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`. +- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls. - Check `gradle/libs.versions.toml` before adding dependencies. +- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code. +- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`. diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 26dbe7685..632bf1ea4 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -213,7 +213,7 @@ jobs: files: "**/build/test-results/**/*.xml" - name: Upload coverage to Codecov - if: ${{ !cancelled() }} + if: ${{ !cancelled() && inputs.run_coverage }} uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md index 6a774297c..acab253d5 100644 --- a/.skills/code-review/SKILL.md +++ b/.skills/code-review/SKILL.md @@ -13,14 +13,15 @@ When reviewing code, meticulously verify the following categories. Flag any devi - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex` - `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()` - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`) - - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` or `expect`/`actual` + - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`) +- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`). - [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`. - [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target. - [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities. ### 2. UI & Compose Multiplatform (CMP) - [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine. -- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. +- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI). - [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`. - [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules. - [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp). @@ -36,8 +37,10 @@ When reviewing code, meticulously verify the following categories. Flag any devi ### 5. Networking, DB & I/O - [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp. +- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths. - [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers. - [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`. +- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead. - [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions. ### 6. Dependency Catalog Aliases @@ -47,7 +50,7 @@ When reviewing code, meticulously verify the following categories. Flag any devi - [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules. ### 7. Testing -- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests. +- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` from `androidx.compose.ui.test.v2` (not the deprecated v1 `androidx.compose.ui.test` package) + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests. - [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`. - [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. - [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues. diff --git a/.skills/compose-ui/SKILL.md b/.skills/compose-ui/SKILL.md index d2e79c542..22fe1b489 100644 --- a/.skills/compose-ui/SKILL.md +++ b/.skills/compose-ui/SKILL.md @@ -14,8 +14,31 @@ Guidelines for building shared UI, adaptive layouts, and handling strings/resour - **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings. - **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context. - **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer). - - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`). + - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`): + ```kotlin + val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5" + stringResource(Res.string.battery_percent, formatted) // uses %1$s + ``` - **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings. + +### String Formatting Decision Tree +Choose the right tool for the job: + +| Scenario | Tool | Example | +|----------|------|---------| +| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)` → `"77.0°F"` | +| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` | +| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` | +| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` | +| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` | +| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` | + +**Rules:** +1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats. +2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls. +3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`. +4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision. + - **Workflow to Add a String:** 1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`. 2. Use the generated `org.meshtastic.core.resources.` symbol. @@ -25,6 +48,13 @@ Guidelines for building shared UI, adaptive layouts, and handling strings/resour - **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules. - **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code. +## 4. Compose Previews +- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables. +- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated. + +## 5. Dialog & State Patterns +- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed. + ## Reference Anchors - **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml` - **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` diff --git a/.skills/implement-feature/SKILL.md b/.skills/implement-feature/SKILL.md index 0e76b30e6..0277bee10 100644 --- a/.skills/implement-feature/SKILL.md +++ b/.skills/implement-feature/SKILL.md @@ -33,7 +33,7 @@ A step-by-step workflow for implementing a new feature in the Meshtastic-Android ### 6. Verify Locally - Run the baseline checks (see `testing-ci` skill): ```bash - ./gradlew spotlessCheck detekt assembleDebug test allTests + ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests ``` - If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds: ```bash diff --git a/.skills/kmp-architecture/SKILL.md b/.skills/kmp-architecture/SKILL.md index 805d9f2f9..46602c430 100644 --- a/.skills/kmp-architecture/SKILL.md +++ b/.skills/kmp-architecture/SKILL.md @@ -16,12 +16,14 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract - **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper. ## 3. Core Libraries & Constraints -- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. +- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic. +- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops). - **Standard Library Replacements:** - `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`. - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`. - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`). - **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging. +- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL. - **BLE:** Route through `core:ble` using **Kable**. - **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`. @@ -38,6 +40,10 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract ## 6. I/O & Serialization - **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision. - **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. +- **Room Patterns:** + - Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic. + - Use `LIMIT 1` on `@Query` methods that expect a single row. + - Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit). ## 7. Build-Logic Conventions - In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md index 557db4717..e92e2cfa3 100644 --- a/.skills/navigation-and-di/SKILL.md +++ b/.skills/navigation-and-di/SKILL.md @@ -15,6 +15,13 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na - **A1 Module Compile Safety:** Do **not** enable A1 `compileSafety`. We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) because of our decoupled Clean Architecture design (interfaces in one module, implemented in another). - **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values. +### Koin Startup Pattern (K2 Compiler Plugin) +The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The correct canonical startup for this path is: +```kotlin +startKoin { modules(AppKoinModule().module()) } +``` +Do **not** use `@KoinApplication` — that annotation is part of the **KSP annotations path** (`koin-ksp-compiler`) and generates a `startKoin()` extension via KSP. It is incompatible with the K2 plugin approach. The two paths are mutually exclusive; the project has deliberately chosen K2 for compile-time wiring without KSP overhead. + ## Navigation 3 ### Guidelines diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md index d7d6af473..291cff488 100644 --- a/.skills/project-overview/SKILL.md +++ b/.skills/project-overview/SKILL.md @@ -73,6 +73,11 @@ Agents **MUST** perform these steps automatically at the start of every session git submodule update --init ``` +3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails: + ```bash + [ -f local.properties ] || cp secrets.defaults.properties local.properties + ``` + ## Troubleshooting - **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts. - **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist. diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 0dca01eb6..2c20258c1 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -5,17 +5,14 @@ Guidelines and commands for verifying code changes locally and understanding the ## 1) Baseline local verification order -Run in this order for routine changes to ensure code formatting, analysis, and basic compilation: +Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation: ```bash -./gradlew clean -./gradlew spotlessCheck -./gradlew spotlessApply -./gradlew detekt -./gradlew assembleDebug -./gradlew test allTests +./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests ``` +> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues. + > **Why `test allTests` and not just `test`:** > In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and > `testAndroidHostTest` and refuses to run either, silently skipping KMP modules. diff --git a/AGENTS.md b/AGENTS.md index ab2549475..07d9b0050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,11 +25,16 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes - **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically: 1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user. 2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds. + 3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails. - **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing. - **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code. - **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first). - **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing: - `./gradlew clean spotlessCheck spotlessApply detekt assembleDebug test allTests` + ``` + ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests + ``` + > **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required. + > For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job). @@ -57,9 +62,10 @@ Do NOT duplicate content into agent-specific files. When you modify architecture - **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application. -- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. -- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety. -- **CMP Over Android:** Use `compose-multiplatform` constraints (e.g., no float formatting in `stringResource`). +- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly. +- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated. +- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code. +- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds. - **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist. - **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution. - **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them. diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 8316ad8e2..0864e55cd 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -45,6 +45,7 @@ import androidx.lifecycle.lifecycleScope import co.touchlab.kermit.Logger import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory +import com.eygraber.uri.toKmpUri import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.android.inject @@ -57,7 +58,6 @@ import org.meshtastic.app.node.component.InlineMap import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.ui.MainScreen import org.meshtastic.core.barcode.rememberBarcodeScanner -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import org.meshtastic.core.nfc.NfcScannerEffect import org.meshtastic.core.resources.Res @@ -278,7 +278,7 @@ class MainActivity : ComponentActivity() { private fun handleMeshtasticUri(uri: Uri) { Logger.d { "Handling Meshtastic URI: $uri" } - model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } } + model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } } } private fun createShareIntent(message: String): PendingIntent { diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index d32cc3df6..34d4797cd 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -28,6 +28,7 @@ import androidx.work.WorkManager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first @@ -57,7 +58,7 @@ open class MeshUtilApplication : Application(), Configuration.Provider { - private val applicationScope = CoroutineScope(Dispatchers.Default) + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onCreate() { super.onCreate() diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt index dd7e9d8be..91ab81ec0 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt @@ -24,6 +24,8 @@ import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.disk.DiskCache import coil3.memory.MemoryCache +import coil3.memoryCacheMaxSizePercentWhileInBackground +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder @@ -31,11 +33,13 @@ import coil3.util.DebugLogger import coil3.util.Logger import io.ktor.client.HttpClient import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.url import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import okio.Path.Companion.toOkioPath @@ -47,6 +51,7 @@ import org.meshtastic.core.network.KermitHttpLogger private const val DISK_CACHE_PERCENT = 0.02 private const val MEMORY_CACHE_PERCENT = 0.25 +private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1 @Module class NetworkModule { @@ -67,7 +72,12 @@ class NetworkModule { buildConfigProvider: BuildConfigProvider, ): ImageLoader = ImageLoader.Builder(context = application) .components { - add(KtorNetworkFetcherFactory(httpClient = httpClient)) + add( + KtorNetworkFetcherFactory( + httpClient = httpClient, + concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(), + ), + ) add(SvgDecoder.Factory(scaleToDensity = true)) } .memoryCache { @@ -80,6 +90,7 @@ class NetworkModule { .build() } .logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null) + .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT) .crossfade(enable = true) .build() @@ -87,6 +98,7 @@ class NetworkModule { fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient = HttpClient(engineFactory = Android) { install(plugin = ContentNegotiation) { json(json) } + install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) } install(plugin = HttpTimeout) { requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index 6af52cd50..be280f29c 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -54,16 +54,18 @@ class KmpFeatureConventionPlugin : Plugin { // Logging implementation(libs.library("kermit")) + + // @Preview available in commonMain since CMP 1.11 (androidx.compose.ui.tooling.preview.Preview) + // org.jetbrains.compose.ui.tooling.preview.Preview is deprecated in 1.11 + implementation(libs.library("compose-multiplatform-ui-tooling-preview")) } sourceSets.getByName("androidMain").dependencies { // Common Android Compose dependencies implementation(libs.library("accompanist-permissions")) implementation(libs.library("androidx-activity-compose")) - implementation(libs.library("compose-multiplatform-material3")) implementation(libs.library("compose-multiplatform-ui")) - implementation(libs.library("compose-multiplatform-ui-tooling-preview")) } sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt index c636d4718..5e85a52f8 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt @@ -48,9 +48,7 @@ suspend fun retryBleOperation( Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" } throw e } - Logger.w(e) { - "[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..." - } + Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." } delay(delayMs) } } diff --git a/core/common/README.md b/core/common/README.md index da7700ac5..979586213 100644 --- a/core/common/README.md +++ b/core/common/README.md @@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers: - **Time**: Utilities for handling timestamps and durations. - **Exceptions**: Standardized exception types for common error scenarios. -### 2. `ByteUtils.kt` -Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets. +### 2. `MetricFormatter.kt` +Centralized utility for display strings — temperature, voltage, current, percent, humidity, pressure, SNR, RSSI. Ensures consistent unit spacing and formatting across all UI surfaces. ### 3. `BuildConfigProvider.kt` An interface for accessing build-time configuration in a multiplatform-friendly way. diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 08ec08865..e4d94943e 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) api(libs.kotlinx.datetime) api(libs.okio) + api(libs.uri.kmp) implementation(libs.kermit) } androidMain.dependencies { api(libs.androidx.core.ktx) } diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt deleted file mode 100644 index a99bccd84..000000000 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import android.net.Uri - -actual class CommonUri(private val uri: Uri) { - actual val host: String? - get() = uri.host - - actual val fragment: String? - get() = uri.fragment - - actual val pathSegments: List - get() = uri.pathSegments - - actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key) - - actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = - uri.getBooleanQueryParameter(key, defaultValue) - - actual override fun toString(): String = uri.toString() - - actual companion object { - actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString)) - } - - fun toUri(): Uri = uri -} - -actual fun CommonUri.toPlatformUri(): Any = this.toUri() diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt deleted file mode 100644 index 7669a66b0..000000000 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import android.net.Uri - -/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */ -fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString) - -/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */ -fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString()) diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt deleted file mode 100644 index c27040e73..000000000 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.core.common - -/** Utility function to make it easy to declare byte arrays */ -fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } - -fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) } - -private const val BYTE_MASK = 0xff diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt similarity index 62% rename from core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt index 0babff5b1..1072801c6 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,13 +17,14 @@ package org.meshtastic.core.common.util /** - * A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain - * modules without coupling them to the android.net.Uri class. + * Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null, + * blank, or sentinel values (`"N"`, `"NULL"`). */ -data class MeshtasticUri(val uriString: String) { - override fun toString(): String = uriString - - companion object { - fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString) +fun normalizeAddress(addr: String?): String { + val u = addr?.trim()?.uppercase() + return when { + u.isNullOrBlank() -> "DEFAULT" + u == "N" || u == "NULL" -> "DEFAULT" + else -> u.replace(":", "") } } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt index 7079cbf5e..00b15861f 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt @@ -16,22 +16,14 @@ */ package org.meshtastic.core.common.util -/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */ -expect class CommonUri { - val host: String? - val fragment: String? - val pathSegments: List +import com.eygraber.uri.Uri - fun getQueryParameter(key: String): String? - - fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean - - override fun toString(): String - - companion object { - fun parse(uriString: String): CommonUri - } -} - -/** Extension to convert platform Uri to CommonUri in Android source sets. */ -expect fun CommonUri.toPlatformUri(): Any +/** + * Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp). + * + * This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works + * identically on Android, JVM, and iOS without platform stubs. + * + * On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`. + */ +typealias CommonUri = Uri diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt index ccd565286..c5d3c2091 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.common.util import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException object Exceptions { /** Set by the application to provide a custom crash reporting implementation. */ @@ -47,10 +48,12 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) { } } -/** Suspend-compatible variant of [ignoreException]. */ +/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */ suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) { try { inner() + } catch (e: CancellationException) { + throw e } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) { if (!silent) { Logger.w(ex) { "Ignoring exception" } @@ -69,3 +72,26 @@ fun exceptionReporter(inner: () -> Unit) { Exceptions.report(ex, "exceptionReporter", "Uncaught Exception") } } + +/** + * Like [kotlin.runCatching], but re-throws [CancellationException] to preserve structured concurrency. Use this instead + * of [runCatching] in coroutine contexts. + */ +@Suppress("TooGenericExceptionCaught") +inline fun safeCatching(block: () -> T): Result = try { + Result.success(block()) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + Result.failure(e) +} + +/** Like [kotlin.runCatching] receiver variant, but re-throws [CancellationException]. */ +@Suppress("TooGenericExceptionCaught") +inline fun T.safeCatching(block: T.() -> R): Result = try { + Result.success(block()) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + Result.failure(e) +} diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt index d54455df8..7a24819a7 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -16,5 +16,114 @@ */ package org.meshtastic.core.common.util -/** Multiplatform string formatting helper. */ -expect fun formatString(pattern: String, vararg args: Any?): String +/** + * Pure-Kotlin multiplatform string formatting. + * + * Implements the subset of Java's `String.format()` patterns used in this codebase: + * - `%s`, `%d` — positional or sequential string/integer + * - `%N$s`, `%N$d` — explicit positional string/integer + * - `%N$.Nf`, `%.Nf` — float with decimal precision + * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width) + * - `%%` — literal percent + */ +@Suppress("CyclomaticComplexMethod", "LongMethod", "LoopWithTooManyJumpStatements") +fun formatString(pattern: String, vararg args: Any?): String = buildString { + var i = 0 + var autoIndex = 0 + while (i < pattern.length) { + if (pattern[i] != '%') { + append(pattern[i]) + i++ + continue + } + i++ // skip '%' + if (i >= pattern.length) break + + // Literal %% + if (pattern[i] == '%') { + append('%') + i++ + continue + } + + // Parse optional positional index (N$) + var explicitIndex: Int? = null + val startPos = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i < pattern.length && pattern[i] == '$' && i > startPos) { + explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed + i++ // skip '$' + } else { + i = startPos // rewind — digits are part of width/precision, not positional index + } + + // Parse optional flags (zero-pad) + var zeroPad = false + if (i < pattern.length && pattern[i] == '0') { + zeroPad = true + i++ + } + + // Parse optional width + var width: Int? = null + val widthStart = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i > widthStart) { + width = pattern.substring(widthStart, i).toInt() + } + + // Parse optional precision (.N) + var precision: Int? = null + if (i < pattern.length && pattern[i] == '.') { + i++ // skip '.' + val precStart = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i > precStart) { + precision = pattern.substring(precStart, i).toInt() + } + } + + // Parse conversion character + if (i >= pattern.length) break + val conversion = pattern[i] + i++ + + val argIndex = explicitIndex ?: autoIndex++ + val arg = args.getOrNull(argIndex) + + when (conversion) { + 's' -> append(arg?.toString() ?: "null") + 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0") + 'f' -> { + val value = (arg as? Number)?.toDouble() ?: 0.0 + val places = precision ?: DEFAULT_FLOAT_PRECISION + append(NumberFormatter.format(value, places)) + } + 'x', + 'X', + -> { + val value = (arg as? Number)?.toLong() ?: 0L + // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour. + val masked = if (arg is Int) value and INT_MASK else value + var hex = masked.toString(HEX_RADIX) + if (conversion == 'X') hex = hex.uppercase() + val padChar = if (zeroPad) '0' else ' ' + val padWidth = width ?: 0 + append(hex.padStart(padWidth, padChar)) + } + else -> { + // Unknown conversion — reproduce original token + append('%') + if (explicitIndex != null) append("${explicitIndex + 1}$") + if (zeroPad) append('0') + if (width != null) append(width) + if (precision != null) append(".$precision") + append(conversion) + } + } + } +} + +private const val DEFAULT_FLOAT_PRECISION = 6 +private const val HEX_RADIX = 16 +private const val INT_MASK = 0xFFFFFFFFL diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt index e3612dfda..1abb8807c 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt @@ -79,9 +79,7 @@ object HomoglyphCharacterStringTransformer { * @param value original string value. * @return optimized string value. */ - fun optimizeUtf8StringWithHomoglyphs(value: String): String { - val stringBuilder = StringBuilder() - for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c) - return stringBuilder.toString() + fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString { + for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c) } } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt new file mode 100644 index 000000000..8e57b4dbb --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +/** + * Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node, + * NodeItem, and metric screens. + * + * All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional + * for a mesh networking app where consistency matters. + */ +object MetricFormatter { + + fun temperature(celsius: Float, isFahrenheit: Boolean): String { + val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius + val unit = if (isFahrenheit) "°F" else "°C" + return "${NumberFormatter.format(value, 1)}$unit" + } + + fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V" + + fun current(milliAmps: Float, decimalPlaces: Int = 1): String = + "${NumberFormatter.format(milliAmps, decimalPlaces)} mA" + + fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%" + + fun percent(value: Int): String = "$value%" + + fun humidity(value: Float): String = percent(value, 0) + + fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa" + + fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB" + + fun rssi(value: Int): String = "$value dBm" +} + +private const val FAHRENHEIT_SCALE = 1.8f +private const val FAHRENHEIT_OFFSET = 32 diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt new file mode 100644 index 000000000..040861b8d --- /dev/null +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AddressUtilsTest { + + @Test + fun nullReturnsDefault() { + assertEquals("DEFAULT", normalizeAddress(null)) + } + + @Test + fun blankReturnsDefault() { + assertEquals("DEFAULT", normalizeAddress("")) + assertEquals("DEFAULT", normalizeAddress(" ")) + } + + @Test + fun sentinelNReturnsDefault() { + assertEquals("DEFAULT", normalizeAddress("N")) + assertEquals("DEFAULT", normalizeAddress("n")) + } + + @Test + fun sentinelNullReturnsDefault() { + assertEquals("DEFAULT", normalizeAddress("NULL")) + assertEquals("DEFAULT", normalizeAddress("null")) + assertEquals("DEFAULT", normalizeAddress("Null")) + } + + @Test + fun stripsColons() { + assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD")) + } + + @Test + fun uppercases() { + assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd")) + } + + @Test + fun trimsWhitespace() { + assertEquals("AABBCC", normalizeAddress(" AA:BB:CC ")) + } + + @Test + fun alreadyNormalizedPassesThrough() { + assertEquals("AABBCCDD", normalizeAddress("AABBCCDD")) + } + + @Test + fun mixedCaseWithColons() { + assertEquals("AABBCC", normalizeAddress("aA:Bb:cC")) + } +} diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt similarity index 65% rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt index 7ca9f9fe8..899938ba4 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt @@ -19,11 +19,25 @@ package org.meshtastic.core.common.util import kotlin.test.Test import kotlin.test.assertEquals -class MeshtasticUriTest { +class CommonUriTest { @Test fun testParseAndToString() { val uriString = "content://com.example.provider/file.txt" - val uri = MeshtasticUri.parse(uriString) + val uri = CommonUri.parse(uriString) assertEquals(uriString, uri.toString()) } + + @Test + fun testQueryParameters() { + val uri = CommonUri.parse("https://meshtastic.org/d/#key=value&complete=true") + assertEquals("meshtastic.org", uri.host) + assertEquals("key=value&complete=true", uri.fragment) + } + + @Test + fun testFileUri() { + val uri = CommonUri.parse("file:///tmp/export.csv") + assertEquals("file", uri.scheme) + assertEquals("/tmp/export.csv", uri.path) + } } diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt index 94b81f0fb..de2d20e9e 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt @@ -93,4 +93,48 @@ class FormatStringTest { fun sequentialFloatSubstitution() { assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45)) } + + // Hex format tests + + @Test + fun lowercaseHex() { + assertEquals("ff", formatString("%x", 255)) + } + + @Test + fun uppercaseHex() { + assertEquals("FF", formatString("%X", 255)) + } + + @Test + fun zeroPaddedHex() { + assertEquals("000000ff", formatString("%08x", 255)) + } + + @Test + fun zeroPaddedHexNodeId() { + assertEquals("!deadbeef", formatString("!%08x", 0xDEADBEEF.toInt())) + } + + @Test + fun hexZeroValue() { + assertEquals("00000000", formatString("%08x", 0)) + } + + @Test + fun positionalHex() { + assertEquals("Node ff id 42", formatString("Node %1\$x id %2\$d", 255, 42)) + } + + // Edge case tests + + @Test + fun trailingPercent() { + assertEquals("hello", formatString("hello%")) + } + + @Test + fun outOfBoundsArgIndex() { + assertEquals("null", formatString("%3\$s", "only_one")) + } } diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt new file mode 100644 index 000000000..b602a4a62 --- /dev/null +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MetricFormatterTest { + + @Test + fun temperatureCelsius() { + assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false)) + } + + @Test + fun temperatureFahrenheit() { + assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true)) + } + + @Test + fun temperatureNegative() { + assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false)) + } + + @Test + fun voltage() { + assertEquals("3.72 V", MetricFormatter.voltage(3.72f)) + } + + @Test + fun voltageOneDecimal() { + assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1)) + } + + @Test + fun current() { + assertEquals("150.3 mA", MetricFormatter.current(150.3f)) + } + + @Test + fun percentFloat() { + assertEquals("85.5%", MetricFormatter.percent(85.5f)) + } + + @Test + fun percentInt() { + assertEquals("85%", MetricFormatter.percent(85)) + } + + @Test + fun humidity() { + assertEquals("65%", MetricFormatter.humidity(65.4f)) + } + + @Test + fun pressure() { + assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f)) + } + + @Test + fun snr() { + assertEquals("5.5 dB", MetricFormatter.snr(5.5f)) + } + + @Test + fun rssi() { + assertEquals("-90 dBm", MetricFormatter.rssi(-90)) + } + + @Test + fun temperatureFreezingFahrenheit() { + assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true)) + } + + @Test + fun temperatureBoilingFahrenheit() { + assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true)) + } + + @Test + fun voltageZero() { + assertEquals("0.00 V", MetricFormatter.voltage(0.0f)) + } + + @Test + fun currentZero() { + assertEquals("0.0 mA", MetricFormatter.current(0.0f)) + } + + @Test + fun percentZero() { + assertEquals("0%", MetricFormatter.percent(0)) + } + + @Test + fun percentHundred() { + assertEquals("100%", MetricFormatter.percent(100)) + } + + @Test + fun rssiZero() { + assertEquals("0 dBm", MetricFormatter.rssi(0)) + } + + @Test + fun snrNegative() { + assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f)) + } +} diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt deleted file mode 100644 index c2e95a5b0..000000000 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -/** - * Apple (iOS) implementation of string formatting. - * - * Implements a subset of Java's `String.format()` patterns used in this codebase: - * - `%s`, `%d` — positional or sequential string/integer - * - `%N$s`, `%N$d` — explicit positional string/integer - * - `%N$.Nf`, `%.Nf` — float with decimal precision - * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width) - * - `%%` — literal percent - * - * This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions). - */ -actual fun formatString(pattern: String, vararg args: Any?): String = buildString { - var i = 0 - var autoIndex = 0 - while (i < pattern.length) { - if (pattern[i] != '%') { - append(pattern[i]) - i++ - continue - } - i++ // skip '%' - if (i >= pattern.length) break - - // Literal %% - if (pattern[i] == '%') { - append('%') - i++ - continue - } - - // Parse optional positional index (N$) - var explicitIndex: Int? = null - val startPos = i - while (i < pattern.length && pattern[i].isDigit()) i++ - if (i < pattern.length && pattern[i] == '$' && i > startPos) { - explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed - i++ // skip '$' - } else { - i = startPos // rewind — digits are part of width/precision, not positional index - } - - // Parse optional flags (zero-pad) - var zeroPad = false - if (i < pattern.length && pattern[i] == '0') { - zeroPad = true - i++ - } - - // Parse optional width - var width: Int? = null - val widthStart = i - while (i < pattern.length && pattern[i].isDigit()) i++ - if (i > widthStart) { - width = pattern.substring(widthStart, i).toInt() - } - - // Parse optional precision (.N) - var precision: Int? = null - if (i < pattern.length && pattern[i] == '.') { - i++ // skip '.' - val precStart = i - while (i < pattern.length && pattern[i].isDigit()) i++ - if (i > precStart) { - precision = pattern.substring(precStart, i).toInt() - } - } - - // Parse conversion character - if (i >= pattern.length) break - val conversion = pattern[i] - i++ - - val argIndex = explicitIndex ?: autoIndex++ - val arg = args.getOrNull(argIndex) - - when (conversion) { - 's' -> append(arg?.toString() ?: "null") - 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0") - 'f' -> { - val value = (arg as? Number)?.toDouble() ?: 0.0 - val places = precision ?: DEFAULT_FLOAT_PRECISION - append(NumberFormatter.format(value, places)) - } - 'x', - 'X', - -> { - val value = (arg as? Number)?.toLong() ?: 0L - // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour. - val masked = if (arg is Int) value and INT_MASK else value - var hex = masked.toString(HEX_RADIX) - if (conversion == 'X') hex = hex.uppercase() - val padChar = if (zeroPad) '0' else ' ' - val padWidth = width ?: 0 - append(hex.padStart(padWidth, padChar)) - } - else -> { - // Unknown conversion — reproduce original token - append('%') - if (explicitIndex != null) append("${explicitIndex + 1}$") - if (zeroPad) append('0') - if (width != null) append(width) - if (precision != null) append(".$precision") - append(conversion) - } - } - } -} - -private const val DEFAULT_FLOAT_PRECISION = 6 -private const val HEX_RADIX = 16 -private const val INT_MASK = 0xFFFFFFFFL diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt index 35e2906ff..7556105b3 100644 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt @@ -22,20 +22,6 @@ actual object BuildUtils { actual val sdkInt: Int = 0 } -actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List) { - actual fun getQueryParameter(key: String): String? = null - - actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue - - actual override fun toString(): String = "" - - actual companion object { - actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList()) - } -} - -actual fun CommonUri.toPlatformUri(): Any = Any() - actual object DateFormatter { actual fun formatRelativeTime(timestampMillis: Long): String = "" diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt deleted file mode 100644 index a450b9856..000000000 --- a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -/** JVM/Android implementation of string formatting. */ -actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args) diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt deleted file mode 100644 index c10c015bc..000000000 --- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import java.net.URI - -actual class CommonUri(private val uri: URI) { - private val queryParameters: Map> by lazy { parseQueryParameters(uri.rawQuery) } - - actual val host: String? - get() = uri.host - - actual val fragment: String? - get() = uri.fragment - - actual val pathSegments: List - get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() } - - actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull() - - actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean { - val value = getQueryParameter(key) ?: return defaultValue - return value != "false" && value != "0" - } - - actual override fun toString(): String = uri.toString() - - actual companion object { - actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString)) - } - - fun toUri(): URI = uri -} - -actual fun CommonUri.toPlatformUri(): Any = this.toUri() diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt index 4b8abdbd3..43ead91a2 100644 --- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt +++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt @@ -17,9 +17,6 @@ package org.meshtastic.core.common.util import java.net.InetAddress -import java.net.URLDecoder -import java.nio.charset.StandardCharsets -import java.text.DateFormat import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -76,7 +73,7 @@ actual object DateFormatter { shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId)) actual fun formatDateTimeShort(timestampMillis: Long): String = - DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis) + shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId)) } @Suppress("MagicNumber") @@ -101,21 +98,6 @@ actual fun String?.isValidAddress(): Boolean { } } -internal fun parseQueryParameters(rawQuery: String?): Map> = rawQuery - ?.split('&') - ?.filter { it.isNotBlank() } - ?.groupBy( - keySelector = { segment -> - val key = segment.substringBefore('=', missingDelimiterValue = segment) - URLDecoder.decode(key, StandardCharsets.UTF_8.name()) - }, - valueTransform = { segment -> - val value = segment.substringAfter('=', missingDelimiterValue = "") - URLDecoder.decode(value, StandardCharsets.UTF_8.name()) - }, - ) - .orEmpty() - private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}") private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?. - */ -package org.meshtastic.core.common.util - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class CommonUriTest { - - @Test - fun testParse() { - val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1¶m2=true#fragment") - assertEquals("meshtastic.org", uri.host) - assertEquals("fragment", uri.fragment) - assertEquals(listOf("path", "to", "page"), uri.pathSegments) - assertEquals("value1", uri.getQueryParameter("param1")) - assertTrue(uri.getBooleanQueryParameter("param2", false)) - } - - @Test - fun testBooleanParameters() { - val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0") - assertTrue(uri.getBooleanQueryParameter("t1", false)) - assertTrue(uri.getBooleanQueryParameter("t2", false)) - assertTrue(uri.getBooleanQueryParameter("t3", false)) - assertTrue(!uri.getBooleanQueryParameter("f1", true)) - assertTrue(!uri.getBooleanQueryParameter("f2", true)) - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt index b0b9e8c5f..628528391 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshPrefs import org.meshtastic.core.repository.PacketHandler @@ -94,7 +95,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan "lastRequest=$lastRequest window=$window max=$max", ) - runCatching { + safeCatching { packetHandler.sendToRadio( MeshPacket( from = myNodeNum, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index 5fd34e02e..975b2f5e8 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -26,6 +26,7 @@ import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ignoreExceptionSuspend import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MeshUser import org.meshtastic.core.model.MessageStatus @@ -93,7 +94,7 @@ class MeshActionHandlerImpl( is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum) is ServiceAction.SendContact -> { val accepted = - runCatching { + safeCatching { commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) } } .getOrDefault(false) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index 31e4f331d..94b405953 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -289,7 +289,7 @@ class MeshConnectionManagerImpl( override fun onRadioConfigLoaded() { scope.handledLaunch { - val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList() + val queuedPackets = packetRepository.getQueuedPackets() queuedPackets.forEach { packet -> try { workerManager.enqueueSendMessage(packet.id) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index 000d0b41d..7a6ec3320 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -96,7 +96,7 @@ class MeshMessageProcessorImpl( } .onFailure { _ -> Logger.e(primaryException) { - "Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord." + "Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord." } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index 338a0d6ea..fdcc6d344 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource @@ -98,7 +99,7 @@ class DeviceHardwareRepositoryImpl( } // 2. Fetch from remote API - runCatching { + safeCatching { Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" } val remoteHardware = remoteDataSource.getAllDeviceHardware() Logger.d { @@ -157,7 +158,7 @@ class DeviceHardwareRepositoryImpl( hwModel: Int, target: String?, quirks: List, - ): Result = runCatching { + ): Result = safeCatching { Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" } val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset() Logger.d { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt index a47a5381f..8f3154815 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource import org.meshtastic.core.database.entity.FirmwareRelease @@ -97,7 +98,7 @@ open class FirmwareReleaseRepositoryImpl( */ private suspend fun updateCacheFromSources() { val remoteFetchSuccess = - runCatching { + safeCatching { Logger.d { "Fetching fresh firmware releases from remote API." } val networkReleases = remoteDataSource.getFirmwareReleases() @@ -110,7 +111,7 @@ open class FirmwareReleaseRepositoryImpl( // If remote fetch failed, try the JSON fallback as a last resort. if (!remoteFetchSuccess) { Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." } - runCatching { + safeCatching { val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset() localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE) localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index f6a49f190..04e09eaf7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -108,7 +108,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val dao.upsertContactSettings(listOf(updated)) } - override suspend fun getQueuedPackets(): List? = + override suspend fun getQueuedPackets(): List = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() } suspend fun insertRoomPacket(packet: RoomPacket) = diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json new file mode 100644 index 000000000..c26991ac4 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json @@ -0,0 +1,1052 @@ +{ + "formatVersion": 1, + "database": { + "version": 38, + "identityHash": "ffca7655fa7c1d69fdd404b1b39d140c", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffca7655fa7c1d69fdd404b1b39d140c')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt index c917ee066..b2c89ad73 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.database import okio.ByteString.Companion.encodeUtf8 +import org.meshtastic.core.common.util.normalizeAddress object DatabaseConstants { const val DB_PREFIX: String = "meshtastic_database" @@ -40,17 +41,6 @@ object DatabaseConstants { const val ADDRESS_ANON_EDGE_LEN: Int = 2 } -fun normalizeAddress(addr: String?): String { - val u = addr?.trim()?.uppercase() - val normalized = - when { - u.isNullOrBlank() -> "DEFAULT" - u == "N" || u == "NULL" -> "DEFAULT" - else -> u.replace(":", "") - } - return normalized -} - fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN) fun buildDbName(address: String?): String = if (address.isNullOrBlank()) { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt index ba5887f95..108345265 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -241,6 +241,7 @@ open class DatabaseManager( victims.forEach { name -> runCatching { + // runCatching intentional: best-effort cleanup must not abort on cancellation closeCachedDatabase(name) deleteDatabase(name) datastore.edit { it.remove(lastUsedKey(name)) } @@ -266,6 +267,7 @@ open class DatabaseManager( if (fs.exists(legacyPath)) { runCatching { + // runCatching intentional: best-effort cleanup must not abort on cancellation closeCachedDatabase(legacy) deleteDatabase(legacy) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 7bf9014ce..13451e5fc 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -94,8 +94,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class), AutoMigration(from = 35, to = 36), AutoMigration(from = 36, to = 37), + AutoMigration(from = 37, to = 38), ], - version = 37, + version = 38, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt index 967a97ec5..35d29c161 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt @@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog @Dao interface MeshLogDao { - @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem") + @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem") fun getAllLogs(maxItem: Int): Flow> - @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem") + @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem") fun getAllLogsInReceiveOrder(maxItem: Int): Flow> /** @@ -40,7 +40,7 @@ interface MeshLogDao { """ SELECT * FROM log WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum) - ORDER BY received_date DESC LIMIT 0,:maxItem + ORDER BY received_date DESC LIMIT :maxItem """, ) fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow> diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index eb3c27b7e..407a4d853 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -35,6 +35,9 @@ interface NodeInfoDao { companion object { const val KEY_SIZE = 32 + + /** SQLite has a limit of ~999 bind parameters per query. */ + const val MAX_BIND_PARAMS = 999 } /** @@ -281,9 +284,15 @@ interface NodeInfoDao { @Transaction suspend fun getNodeByNum(num: Int): NodeWithRelations? + @Query("SELECT * FROM nodes WHERE num IN (:nodeNums)") + suspend fun getNodeEntitiesByNums(nodeNums: List): List + @Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1") suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity? + @Query("SELECT * FROM nodes WHERE public_key IN (:publicKeys)") + suspend fun findNodesByPublicKeys(publicKeys: List): List + @Upsert suspend fun doUpsert(node: NodeEntity) @Transaction @@ -297,11 +306,77 @@ interface NodeInfoDao { @Query("UPDATE nodes SET notes = :notes WHERE num = :num") suspend fun setNodeNotes(num: Int, notes: String) + /** + * Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two + * queries instead of N individual queries, then processes each node in memory. + */ + @Suppress("NestedBlockDepth") + private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List { + // Prepare all incoming nodes (populate denormalized fields) + incomingNodes.forEach { node -> + node.publicKey = node.user.public_key + if (node.user.hw_model != HardwareModel.UNSET) { + node.longName = node.user.long_name + node.shortName = node.user.short_name + } else { + node.longName = null + node.shortName = null + } + } + + // Batch fetch all existing nodes by num (chunked for SQLite bind-param limit) + val existingNodesMap = + incomingNodes + .map { it.num } + .chunked(MAX_BIND_PARAMS) + .flatMap { getNodeEntitiesByNums(it) } + .associateBy { it.num } + + // Partition into updates vs. inserts and resolve existing nodes in-memory + val result = mutableListOf() + val newNodes = mutableListOf() + for (incoming in incomingNodes) { + val existing = existingNodesMap[incoming.num] + if (existing != null) { + result.add(handleExistingNodeUpsertValidation(existing, incoming)) + } else { + newNodes.add(incoming) + } + } + + // Batch validate new nodes' public keys (one query instead of N) + val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct() + val pkConflicts = + if (publicKeysToCheck.isNotEmpty()) { + publicKeysToCheck + .chunked(MAX_BIND_PARAMS) + .flatMap { findNodesByPublicKeys(it) } + .associateBy { it.publicKey } + } else { + emptyMap() + } + + for (newNode in newNodes) { + if ((newNode.publicKey?.size ?: 0) > 0) { + val conflicting = pkConflicts[newNode.publicKey] + if (conflicting != null && conflicting.num != newNode.num) { + result.add(conflicting) + } else { + result.add(newNode) + } + } else { + result.add(newNode) + } + } + + return result + } + @Transaction suspend fun installConfig(mi: MyNodeEntity, nodes: List) { clearMyNodeInfo() setMyNodeInfo(mi) - putAll(nodes.map { getVerifiedNodeForUpsert(it) }) + putAll(getVerifiedNodesForUpsert(nodes)) } /** diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 1419d51e7..71017799c 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -18,7 +18,9 @@ package org.meshtastic.core.database.dao import androidx.paging.PagingSource import androidx.room3.Dao +import androidx.room3.Insert import androidx.room3.MapColumn +import androidx.room3.OnConflictStrategy import androidx.room3.Query import androidx.room3.Transaction import androidx.room3.Update @@ -326,8 +328,15 @@ interface PacketDao { ) suspend fun findPacketBySfppHash(hash: ByteString): Packet? - @Transaction - suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED } + @Query( + """ + SELECT data FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND json_extract(data, '${"$"}.status') = 'QUEUED' + ORDER BY received_time ASC + """, + ) + suspend fun getQueuedPackets(): List @Query( """ @@ -359,23 +368,24 @@ interface PacketDao { @Upsert suspend fun upsertContactSettings(contacts: List) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertContactSettingsIgnore(contacts: List) + + @Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)") + suspend fun updateMuteUntil(contactKeys: List, muteUntil: Long) + @Transaction suspend fun setMuteUntil(contacts: List, until: Long) { - val contactList = contacts.map { contact -> - // Always mute - val absoluteMuteUntil = - if (until == Long.MAX_VALUE) { - Long.MAX_VALUE - } else if (until == 0L) { // unmute - 0L - } else { - nowMillis + until - } - - getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil) - ?: ContactSettings(contact_key = contact, muteUntil = absoluteMuteUntil) - } - upsertContactSettings(contactList) + val absoluteMuteUntil = + when { + until == Long.MAX_VALUE -> Long.MAX_VALUE + until == 0L -> 0L + else -> nowMillis + until + } + // Ensure rows exist for all contacts (IGNORE avoids overwriting existing data) + insertContactSettingsIgnore(contacts.map { ContactSettings(contact_key = it) }) + // Atomic column-level update — no read-then-write race + updateMuteUntil(contacts, absoluteMuteUntil) } @Upsert suspend fun insert(reaction: ReactionEntity) @@ -479,9 +489,10 @@ interface PacketDao { val indexMap = oldSettings .mapIndexed { oldIndex, oldChannel -> - val pskMatches = newSettings.mapIndexedNotNull { index, channel -> - if (channel.psk == oldChannel.psk) index to channel else null - } + val pskMatches = + newSettings.mapIndexedNotNull { index, channel -> + if (channel.psk == oldChannel.psk) index to channel else null + } val newIndex = when { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index 13d10193c..fed88eef9 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -118,6 +118,7 @@ data class MetadataEntity( Index(value = ["hops_away"]), Index(value = ["is_favorite"]), Index(value = ["last_heard", "is_favorite"]), + Index(value = ["public_key"]), ], ) data class NodeEntity( diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt index 16b1e66e4..d01171751 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -74,6 +74,9 @@ data class PacketEntity( Index(value = ["contact_key"]), Index(value = ["contact_key", "port_num", "received_time"]), Index(value = ["packet_id"]), + Index(value = ["received_time"]), + Index(value = ["filtered"]), + Index(value = ["read"]), ], ) data class Packet( @@ -98,9 +101,12 @@ data class Packet( fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK - val candidateRelayNodes = nodes.filter { - it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix - } + val candidateRelayNodes = + nodes.filter { + it.num != ourNodeNum && + it.lastHeard != 0 && + (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix + } val closestRelayNode = if (candidateRelayNodes.size == 1) { diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt deleted file mode 100644 index 473e482e2..000000000 --- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model.util - -import org.meshtastic.core.common.util.nowInstant -import org.meshtastic.core.common.util.toDate -import org.meshtastic.core.common.util.toInstant -import java.text.DateFormat -import kotlin.time.Duration.Companion.hours - -private val DAY_DURATION = 24.hours - -/** - * Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string - * representing the date. - * - * @param time The time in milliseconds - * @return Formatted date or time string, or null if time is 0 - */ -fun getShortDate(time: Long): String? { - if (time == 0L) return null - val instant = time.toInstant() - val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION - - return if (isWithin24Hours) { - DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate()) - } else { - DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate()) - } -} - -/** - * Calculates the remaining mute time in days and hours. - * - * @param remainingMillis The remaining time in milliseconds - * @return Pair of (days, hours), where days is Int and hours is Double - */ diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt index 13b0789de..99debb5ab 100644 --- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt +++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt @@ -17,12 +17,13 @@ package org.meshtastic.core.model.util import android.net.Uri +import com.eygraber.uri.toKmpUri import org.meshtastic.core.common.util.CommonUri import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.SharedContact /** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */ -fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString()) +fun Uri.toCommonUri(): CommonUri = this.toKmpUri() /** Bridge extension for Android clients. */ fun Uri.dispatchMeshtasticUri( diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 13eccae2a..70dea8574 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -19,10 +19,9 @@ package org.meshtastic.core.model import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.GPSFormat +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.bearing -import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.latLongToMeter -import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.proto.Config @@ -143,34 +142,26 @@ data class Node( private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List { val temp = if ((temperature ?: 0f) != 0f) { - if (isFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f)) - } else { - formatString("%.1f°C", temperature) - } + MetricFormatter.temperature(temperature ?: 0f, isFahrenheit) } else { null } - val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null + val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null val soilTemperatureStr = if ((soil_temperature ?: 0f) != 0f) { - if (isFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f)) - } else { - formatString("%.1f°C", soil_temperature) - } + MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit) } else { null } val soilMoistureRange = 0..100 val soilMoisture = if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) { - formatString("%d%%", soil_moisture) + MetricFormatter.percent(soil_moisture ?: 0) } else { null } - val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null - val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null + val voltage = if ((this.voltage ?: 0f) != 0f) MetricFormatter.voltage(this.voltage ?: 0f) else null + val current = if ((current ?: 0f) != 0f) MetricFormatter.current(current ?: 0f) else null val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null return listOfNotNull( @@ -199,9 +190,12 @@ data class Node( fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK - val candidateRelayNodes = nodes.filter { - it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix - } + val candidateRelayNodes = + nodes.filter { + it.num != ourNodeNum && + it.lastHeard != 0 && + (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix + } val closestRelayNode = if (candidateRelayNodes.size == 1) { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt index 6f27bb0e6..47d812f68 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -32,7 +32,7 @@ val Any?.anonymize: String get() = this.anonymize() /** A version of anonymize that allows passing in a custom minimum length */ -fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null" +fun Any?.anonymize(maxLen: Int = 3) = if (this != null) "...${this.toString().takeLast(maxLen)}" else "null" // A toString that makes sure all newlines are removed (for nice logging). fun Any.toOneLineString() = this.toString().replace('\n', ' ') diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt index ca035a7fd..ebdcc0f5e 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt @@ -16,7 +16,27 @@ */ package org.meshtastic.core.model.util +import okio.ByteString.Companion.toByteString + /** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */ -expect object SfppHasher { - fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray +object SfppHasher { + private const val HASH_SIZE = 16 + private const val INT_BYTES = 4 + private const val INT_COUNT = 3 + private const val SHIFT_8 = 8 + private const val SHIFT_16 = 16 + private const val SHIFT_24 = 24 + + fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray { + val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT) + encryptedPayload.copyInto(input) + var offset = encryptedPayload.size + for (value in intArrayOf(to, from, id)) { + input[offset++] = value.toByte() + input[offset++] = (value shr SHIFT_8).toByte() + input[offset++] = (value shr SHIFT_16).toByte() + input[offset++] = (value shr SHIFT_24).toByte() + } + return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE) + } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt index b2e175382..4b3f5d149 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt @@ -107,7 +107,7 @@ fun compareUsers(oldUser: User, newUser: User): String { return if (changes.isEmpty()) { "No changes detected." } else { - "Changes:\n" + changes.joinToString("\n") + "Changes:\n${changes.joinToString("\n")}" } } diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt similarity index 95% rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt rename to core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt index 51f6a5c76..14dfd72c8 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt @@ -14,12 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.common +package org.meshtastic.core.model.util import kotlin.test.Test import kotlin.test.assertEquals -class ByteUtilsTest { +class CommonUtilsTest { @Test fun testByteArrayOfInts() { diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt new file mode 100644 index 000000000..917414e3d --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SfppHasherTest { + + @Test + fun outputIsAlways16Bytes() { + val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1) + assertEquals(16, hash.size) + } + + @Test + fun emptyPayloadProduces16Bytes() { + val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0) + assertEquals(16, hash.size) + } + + @Test + fun deterministicOutput() { + val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3) + val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3) + assertEquals(a.toList(), b.toList()) + } + + @Test + fun differentPayloadsProduceDifferentHashes() { + val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3) + val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3) + assertNotEquals(a.toList(), b.toList()) + } + + @Test + fun differentIdsProduceDifferentHashes() { + val payload = byteArrayOf(0x10, 0x20) + val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100) + val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101) + assertNotEquals(a.toList(), b.toList()) + } + + @Test + fun differentFromProduceDifferentHashes() { + val payload = byteArrayOf(0x10, 0x20) + val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3) + val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3) + assertNotEquals(a.toList(), b.toList()) + } + + @Test + fun maxIntValues() { + val hash = + SfppHasher.computeMessageHash( + byteArrayOf(0xFF.toByte()), + to = Int.MAX_VALUE, + from = Int.MAX_VALUE, + id = Int.MAX_VALUE, + ) + assertEquals(16, hash.size) + } + + @Test + fun littleEndianByteOrder() { + // Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian) + val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0) + val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0) + // Different byte orderings must produce different hashes + assertNotEquals(hashA.toList(), hashB.toList()) + } +} diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt index 7545a00a7..d17abd4a3 100644 --- a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt +++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt @@ -20,7 +20,3 @@ package org.meshtastic.core.model.util actual fun getShortDateTime(time: Long): String = "" actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size) - -actual object SfppHasher { - actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32) -} diff --git a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt deleted file mode 100644 index b1c25110b..000000000 --- a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model.util - -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.security.MessageDigest - -actual object SfppHasher { - private const val HASH_SIZE = 16 - private const val INT_BYTES = 4 - - actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray { - val digest = MessageDigest.getInstance("SHA-256") - digest.update(encryptedPayload) - digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array()) - digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array()) - digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array()) - return digest.digest().copyOf(HASH_SIZE) - } -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt index db558bedb..87c317024 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt @@ -28,4 +28,7 @@ object HttpClientDefaults { /** Maximum number of automatic retries on server errors (5xx). */ const val MAX_RETRIES = 3 + + /** Base URL for the Meshtastic public API. Installed via the `DefaultRequest` plugin. */ + const val API_BASE_URL = "https://api.meshtastic.org/" } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt index 78d3d4ceb..b14c1bfe4 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt @@ -326,8 +326,8 @@ class MockRadioTransport( user = User( id = DataPacket.nodeNumToDefaultId(numIn), - long_name = "Sim " + numIn.toString(16), - short_name = getInitials("Sim " + numIn.toString(16)), + long_name = "Sim ${numIn.toString(16)}", + short_name = getInitials("Sim ${numIn.toString(16)}"), hw_model = HardwareModel.ANDROID_SIM, ), position = diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt index ed7461058..6c15478d9 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt @@ -35,14 +35,14 @@ interface ApiService { /** * Ktor-based [ApiService] implementation. * + * Uses relative paths — the base URL is set via the `DefaultRequest` plugin in the platform Koin modules. + * * Registered with `binds = []` to prevent Koin from auto-binding to [ApiService]; host modules (`app`, `desktop`) * provide their own explicit `ApiService` binding to allow platform-specific `HttpClient` engines. */ @Single(binds = []) class ApiServiceImpl(private val client: HttpClient) : ApiService { - override suspend fun getDeviceHardware(): List = - client.get("https://api.meshtastic.org/resource/deviceHardware").body() + override suspend fun getDeviceHardware(): List = client.get("resource/deviceHardware").body() - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = - client.get("https://api.meshtastic.org/github/firmware/list").body() + override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body() } diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt index 1b46232bf..34b9e49a3 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt @@ -17,12 +17,12 @@ package org.meshtastic.core.network.repository import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOn import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers import java.io.IOException import java.net.InetAddress import java.net.NetworkInterface @@ -31,7 +31,7 @@ import javax.jmdns.ServiceEvent import javax.jmdns.ServiceListener @Single -class JvmServiceDiscovery : ServiceDiscovery { +class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : ServiceDiscovery { @Suppress("TooGenericExceptionCaught") override val resolvedServices: Flow> = callbackFlow { @@ -98,7 +98,7 @@ class JvmServiceDiscovery : ServiceDiscovery { } } } - .flowOn(Dispatchers.IO) + .flowOn(dispatchers.io) companion object { /** diff --git a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt index e03076f39..5884daaaf 100644 --- a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt +++ b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt @@ -17,16 +17,23 @@ package org.meshtastic.core.network.repository import app.cash.turbine.test +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.meshtastic.core.di.CoroutineDispatchers import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertTrue class JvmServiceDiscoveryTest { + private val testDispatchers = + UnconfinedTestDispatcher().let { dispatcher -> + CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) + } + @Test fun `resolvedServices emits initial empty list immediately`() = runTest { - val discovery = JvmServiceDiscovery() + val discovery = JvmServiceDiscovery(testDispatchers) discovery.resolvedServices.test { val first = awaitItem() assertNotNull(first, "First emission should not be null") diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt index ad982e6a6..2292ea3ab 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.normalizeAddress import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.cachedFlow import org.meshtastic.core.repository.MeshPrefs @@ -95,15 +96,6 @@ class MeshPrefsImpl( private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}" - private fun normalizeAddress(address: String?): String { - val raw = address?.trim()?.takeIf { it.isNotEmpty() } - return when { - raw == null -> "DEFAULT" - raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT" - else -> raw.uppercase().replace(":", "") - } - } - companion object { val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address") } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt index dca2a6bf3..9f7cbe0dd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt @@ -18,7 +18,7 @@ package org.meshtastic.core.repository import okio.BufferedSink import okio.BufferedSource -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri /** * Abstracts file system operations (like reading from or writing to URIs) so that ViewModels can remain @@ -29,11 +29,11 @@ interface FileService { * Opens a file or URI for writing and provides a [BufferedSink]. The sink is automatically closed after [block] * execution. Returns true if successful, false otherwise. */ - suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean + suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean /** * Opens a file or URI for reading and provides a [BufferedSource]. The source is automatically closed after [block] * execution. Returns true if successful, false otherwise. */ - suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean + suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt index a0977c582..6bd33a4cf 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt @@ -71,7 +71,7 @@ interface PacketRepository { suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) /** Returns all packets currently queued for transmission. */ - suspend fun getQueuedPackets(): List? + suspend fun getQueuedPackets(): List /** * Persists a packet in the database. diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 77c923d94..a958ce1ee 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -384,9 +384,9 @@ Battery ChUtil AirUtil - %1$s: %2$.1f%% - %1$s: %2$.1f V - %1$.1f + %1$s: %2$s%% + %1$s: %2$s V + %1$s %1$s: %2$s Temp Hum diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt index 91eb97484..8b939fa9b 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt @@ -16,9 +16,11 @@ */ package org.meshtastic.core.service +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith +import org.meshtastic.core.di.CoroutineDispatchers import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @@ -27,10 +29,15 @@ import kotlin.test.assertNotNull @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class AndroidFileServiceTest { + private val testDispatchers = + UnconfinedTestDispatcher().let { dispatcher -> + CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) + } + @Test fun testInitialization() = runTest { val context = RuntimeEnvironment.getApplication() - val service = AndroidFileService(context) + val service = AndroidFileService(context, testDispatchers) assertNotNull(service) } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt index 010fcdc89..8924cdcc8 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt @@ -18,7 +18,7 @@ package org.meshtastic.core.service import android.app.Application import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers +import com.eygraber.uri.toAndroidUri import kotlinx.coroutines.withContext import okio.BufferedSink import okio.BufferedSource @@ -26,15 +26,16 @@ import okio.buffer import okio.sink import okio.source import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.MeshtasticUri -import org.meshtastic.core.common.util.toAndroidUri +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import java.io.FileOutputStream @Single -class AndroidFileService(private val context: Application) : FileService { - override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean = - withContext(Dispatchers.IO) { +class AndroidFileService(private val context: Application, private val dispatchers: CoroutineDispatchers) : + FileService { + override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean = + withContext(dispatchers.io) { try { val pfd = context.contentResolver.openFileDescriptor(uri.toAndroidUri(), "wt") if (pfd == null) { @@ -51,8 +52,8 @@ class AndroidFileService(private val context: Application) : FileService { } } - override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean = - withContext(Dispatchers.IO) { + override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean = + withContext(dispatchers.io) { try { val success = context.contentResolver.openInputStream(uri.toAndroidUri())?.use { inputStream -> diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt index 8f8e08d45..5b3d6df0d 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okio.BufferedSink import okio.BufferedSource @@ -25,17 +24,18 @@ import okio.buffer import okio.sink import okio.source import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import java.io.File @Single -class JvmFileService : FileService { - override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean = - withContext(Dispatchers.IO) { +class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileService { + override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean = + withContext(dispatchers.io) { try { - // Treat uriString as a local file path - val file = File(uri.uriString) + // Treat URI string as a local file path + val file = File(uri.toString()) file.parentFile?.mkdirs() file.sink().buffer().use { sink -> block(sink) } true @@ -45,10 +45,10 @@ class JvmFileService : FileService { } } - override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean = - withContext(Dispatchers.IO) { + override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean = + withContext(dispatchers.io) { try { - val file = File(uri.uriString) + val file = File(uri.toString()) file.source().buffer().use { source -> block(source) } true } catch (e: Exception) { diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt index cd616417d..732d03064 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt @@ -20,47 +20,41 @@ package org.meshtastic.core.takserver import kotlin.time.Instant -fun CoTMessage.toXml(): String { - val sb = StringBuilder() - sb.append( +fun CoTMessage.toXml(): String = buildString { + append( "", ) contact?.let { - sb.append( + append( "", ) } - group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") } + group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") } - status?.let { sb.append("") } + status?.let { append("") } - track?.let { sb.append("") } + track?.let { append("") } if (chat != null) { val senderUid = uid.geoChatSenderUid() val messageId = uid.geoChatMessageId() - sb.append( + append( "<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'>", ) - sb.append("") - sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>") - sb.append( + append("") + append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>") + append( "${chat.message.xmlEscaped()}", ) } else if (!remarks.isNullOrEmpty()) { - sb.append("${remarks.xmlEscaped()}") + append("${remarks.xmlEscaped()}") } - rawDetailXml?.let { - if (it.isNotEmpty()) { - sb.append(it) - } - } + rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) } - sb.append("") - return sb.toString() + append("") } private fun Instant.toXmlString(): String = this.toString() diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt index 65d7077f9..48c635560 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt @@ -16,12 +16,16 @@ */ package org.meshtastic.core.takserver.fountain +import okio.ByteString.Companion.toByteString + internal expect object ZlibCodec { fun compress(data: ByteArray): ByteArray? fun decompress(data: ByteArray): ByteArray? } -internal expect object CryptoCodec { - fun sha256Prefix8(data: ByteArray): ByteArray +internal object CryptoCodec { + private const val PREFIX_SIZE = 8 + + fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE) } diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt similarity index 83% rename from core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt rename to core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt index 4473fc521..b0e4f1030 100644 --- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt @@ -24,8 +24,6 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.reinterpret import kotlinx.cinterop.usePinned import kotlinx.cinterop.value -import platform.CoreCrypto.CC_SHA256 -import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH import platform.zlib.Z_BUF_ERROR import platform.zlib.Z_OK import platform.zlib.compress @@ -105,20 +103,3 @@ internal actual object ZlibCodec { return null } } - -internal actual object CryptoCodec { - @OptIn(ExperimentalForeignApi::class) - actual fun sha256Prefix8(data: ByteArray): ByteArray { - val digest = ByteArray(CC_SHA256_DIGEST_LENGTH) - if (data.isNotEmpty()) { - data.usePinned { dataPin -> - digest.usePinned { digestPin -> - CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret()) - } - } - } else { - digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) } - } - return digest.copyOf(8) - } -} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt similarity index 90% rename from core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt rename to core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt index 9db28ac66..fca9f0f52 100644 --- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.takserver.fountain import java.io.ByteArrayOutputStream -import java.security.MessageDigest import java.util.zip.Deflater import java.util.zip.Inflater @@ -66,10 +65,3 @@ internal actual object ZlibCodec { } } } - -internal actual object CryptoCodec { - actual fun sha256Prefix8(data: ByteArray): ByteArray { - val digest = MessageDigest.getInstance("SHA-256") - return digest.digest(data).copyOf(8) - } -} diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index bebed2f46..231c84d40 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -20,7 +20,6 @@ package org.meshtastic.core.ui.util import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -36,13 +35,14 @@ import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import co.touchlab.kermit.Logger +import com.eygraber.uri.toAndroidUri +import com.eygraber.uri.toKmpUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri import java.net.URLEncoder @Composable @@ -107,16 +107,14 @@ actual fun rememberOpenUrl(): (url: String) -> Unit { @Composable @Suppress("Wrapping") actual fun rememberSaveFileLauncher( - onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit, + onUriReceived: (org.meshtastic.core.common.util.CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit { val launcher = androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(), ) { result -> if (result.resultCode == android.app.Activity.RESULT_OK) { - result.data?.data?.let { uri -> - onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) }) - } + result.data?.data?.let { uri -> onUriReceived(uri.toKmpUri()) } } } @@ -137,7 +135,7 @@ actual fun rememberSaveFileLauncher( actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit { val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - onUriReceived(uri?.let { CommonUri(it) }) + onUriReceived(uri?.let { it.toKmpUri() }) } return remember(launcher) { { mimeType -> launcher.launch(mimeType) } } } @@ -151,7 +149,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> withContext(Dispatchers.IO) { @Suppress("TooGenericExceptionCaught") try { - val androidUri = Uri.parse(uri.toString()) + val androidUri = uri.toAndroidUri() context.contentResolver.openInputStream(androidUri)?.use { stream -> stream.bufferedReader().use { reader -> val buffer = CharArray(maxChars) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt index 9d41d5f5a..22c6bfaf5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt @@ -62,12 +62,13 @@ fun > DropDownPreference( enumEntriesOf(selectedItem).filter { it.name != "UNRECOGNIZED" && !it.isDeprecatedEnumEntry() } } - val items = enumConstants.map { - val label = itemLabel?.invoke(it) ?: it.name - val icon = itemIcon?.invoke(it) - val color = itemColor?.invoke(it) - DropDownItem(it, label, icon, color) - } + val items = + enumConstants.map { + val label = itemLabel?.invoke(it) ?: it.name + val icon = itemIcon?.invoke(it) + val color = itemColor?.invoke(it) + DropDownItem(it, label, icon, color) + } DropDownPreference( title = title, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt index 2dce97aa5..10b83ce41 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt @@ -23,7 +23,7 @@ import androidx.compose.material3.IconToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction @@ -49,7 +49,7 @@ fun EditPasswordPreference( onValueChanged: (String) -> Unit, modifier: Modifier = Modifier, ) { - var isPasswordVisible by remember { mutableStateOf(false) } + var isPasswordVisible by rememberSaveable { mutableStateOf(false) } EditTextPreference( title = title, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt index c461a065f..d8df4101b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -90,10 +91,10 @@ fun MeshtasticImportFAB( ) { sharedContact?.let { importDialog(it, onDismissSharedContact) } - var expanded by remember { mutableStateOf(false) } - var showUrlDialog by remember { mutableStateOf(false) } - var isNfcScanning by remember { mutableStateOf(false) } - var showNfcDisabledDialog by remember { mutableStateOf(false) } + var expanded by rememberSaveable { mutableStateOf(false) } + var showUrlDialog by rememberSaveable { mutableStateOf(false) } + var isNfcScanning by rememberSaveable { mutableStateOf(false) } + var showNfcDisabledDialog by rememberSaveable { mutableStateOf(false) } val openNfcSettings = rememberOpenNfcSettings() val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt index 216ec2108..753468600 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt @@ -41,7 +41,7 @@ import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bad import org.meshtastic.core.resources.fair @@ -154,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) { Text( modifier = modifier, - text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr), + text = "${stringResource(Res.string.snr)} ${MetricFormatter.snr(snr, decimalPlaces = 2)}", color = color, style = MaterialTheme.typography.labelSmall, ) @@ -172,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) { } Text( modifier = modifier, - text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi), + text = "${stringResource(Res.string.rssi)} ${MetricFormatter.rssi(rssi)}", color = color, style = MaterialTheme.typography.labelSmall, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt index 7e8bd9b6a..1445bdedf 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.icon.BatteryEmpty @@ -49,7 +49,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusRed -private const val FORMAT = "%d%%" private const val SIZE_ICON = 16 @Suppress("MagicNumber", "LongMethod") @@ -60,7 +59,7 @@ fun MaterialBatteryInfo( voltage: Float? = null, contentColor: Color = MaterialTheme.colorScheme.onSurface, ) { - val levelString = formatString(FORMAT, level) + val levelString = level?.let { MetricFormatter.percent(it) } ?: stringResource(Res.string.unknown) Row( modifier = modifier, @@ -130,7 +129,7 @@ fun MaterialBatteryInfo( ?.takeIf { it > 0 } ?.let { Text( - text = formatString("%.2fV", it), + text = MetricFormatter.voltage(it), color = contentColor.copy(alpha = 0.8f), style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp), ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt index 5a6c58c23..f817ec4e4 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.signal_quality @@ -65,7 +65,10 @@ fun SignalInfo( tint = signalColor, ) Text( - text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)), + text = + "${MetricFormatter.snr( + node.snr, + )} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt index b0e01011e..4a710b0b3 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt @@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -117,8 +118,8 @@ fun EmojiPickerDialog( onConfirm: (String) -> Unit, ) { val viewModel: EmojiPickerViewModel = koinViewModel() - var searchQuery by remember { mutableStateOf("") } - var selectedCategoryIndex by remember { mutableStateOf(0) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) } val recentEmojis by remember(viewModel.customEmojiFrequency) { derivedStateOf { parseRecents(viewModel.customEmojiFrequency) } } @@ -427,7 +428,7 @@ private fun SectionHeader(title: String) { @OptIn(ExperimentalFoundationApi::class) @Composable private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (String) -> Unit) { - var showSkinTonePopup by remember { mutableStateOf(false) } + var showSkinTonePopup by rememberSaveable { mutableStateOf(false) } Box { Box( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index d5f4e31ec..7e5271148 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -89,7 +90,7 @@ fun ScannedQrCodeDialog( onDismiss: () -> Unit, onConfirm: (ChannelSet) -> Unit, ) { - var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) } + var shouldReplace by rememberSaveable { mutableStateOf(incoming.lora_config != null) } val channelSet = remember(shouldReplace, channels, incoming) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 38e870314..9d3169c1a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -21,7 +21,6 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri /** Returns a function to open the platform's NFC settings. */ @Composable expect fun rememberOpenNfcSettings(): () -> Unit @@ -41,7 +40,7 @@ import org.meshtastic.core.common.util.MeshtasticUri /** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */ @Composable expect fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit /** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */ diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index 12f1ea0f5..edfda074c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -36,7 +36,6 @@ import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.MyNodeInfo @@ -99,18 +98,16 @@ class UIViewModel( * 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via * [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations. */ - fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) { - val commonUri = CommonUri.parse(uri.uriString) - + fun handleDeepLink(uri: CommonUri, onInvalid: () -> Unit = {}) { // Try navigation routing first - val navKeys = DeepLinkRouter.route(commonUri) + val navKeys = DeepLinkRouter.route(uri) if (navKeys != null) { _navigationDeepLink.tryEmit(navKeys) return } // Fallback to channel/contact importing - commonUri.dispatchMeshtasticUri( + uri.dispatchMeshtasticUri( onContact = { setSharedContactRequested(it) }, onChannel = { setRequestChannelSet(it) }, onInvalid = onInvalid, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt index b85e68888..905d50c2b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt @@ -21,6 +21,7 @@ package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -37,7 +38,6 @@ import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.unknown_error import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index 0621463bd..ebe791f8e 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLinkStyles import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri actual fun createClipEntry(text: String, label: String): ClipEntry = throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub") @@ -41,7 +40,7 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A @Composable actual fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> } @Composable diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 08c414490..031e1fe35 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri import java.awt.Desktop import java.awt.FileDialog import java.awt.Frame @@ -61,7 +60,7 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url -> /** JVM — Opens a native file dialog to save a file. */ @Composable actual fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ -> val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE) dialog.file = defaultFilename @@ -70,7 +69,7 @@ actual fun rememberSaveFileLauncher( val dir = dialog.directory if (file != null && dir != null) { val path = File(dir, file) - onUriReceived(MeshtasticUri(path.toURI().toString())) + onUriReceived(CommonUri.parse(path.toURI().toString())) } } @@ -83,7 +82,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT val dir = dialog.directory if (file != null && dir != null) { val path = File(dir, file) - onUriReceived(CommonUri(path.toURI())) + onUriReceived(CommonUri.parse(path.toURI().toString())) } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 11111dd7a..80e049bce 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -51,6 +51,7 @@ import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory import coil3.disk.DiskCache import coil3.memory.MemoryCache +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder @@ -62,7 +63,7 @@ import org.jetbrains.compose.resources.decodeToSvgPainter import org.koin.compose.koinInject import org.koin.core.context.startKoin import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.desktopDataDir import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.SettingsRoute @@ -130,7 +131,7 @@ private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: U arg.startsWith("http://meshtastic.org") || arg.startsWith("https://meshtastic.org") ) { - uiViewModel.handleDeepLink(MeshtasticUri(arg)) { + uiViewModel.handleDeepLink(CommonUri.parse(arg)) { Logger.e { "Invalid Meshtastic URI passed via args: $arg" } } } @@ -141,7 +142,7 @@ private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: U if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) { Desktop.getDesktop().setOpenURIHandler { event -> val uriStr = event.uri.toString() - uiViewModel.handleDeepLink(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } + uiViewModel.handleDeepLink(CommonUri.parse(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } } } } @@ -304,7 +305,12 @@ private fun CoilImageLoaderSetup() { val cacheDir = desktopDataDir() + "/image_cache_v3" ImageLoader.Builder(context) .components { - add(KtorNetworkFetcherFactory(httpClient = httpClient)) + add( + KtorNetworkFetcherFactory( + httpClient = httpClient, + concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(), + ), + ) // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts // that show up as solid/black hardware images. add(SvgDecoder.Factory(renderToBitmap = true)) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 5b3b03f9d..8ac634112 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -14,18 +14,22 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("ktlint:standard:no-unused-imports") // Koin KSP-generated extension functions require aliased imports +@file:Suppress( + "ktlint:standard:no-unused-imports", +) // Koin K2 compiler plugin generates aliased module extensions referenced in desktopModule() package org.meshtastic.desktop.di // Generated Koin module extensions from core KMP modules import io.ktor.client.HttpClient import io.ktor.client.engine.java.Java +import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.url import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import org.koin.dsl.module @@ -183,6 +187,7 @@ private fun desktopPlatformStubsModule() = module { single { HttpClient(Java) { install(ContentNegotiation) { json(get()) } + install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) } install(HttpTimeout) { requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS @@ -195,7 +200,7 @@ private fun desktopPlatformStubsModule() = module { if (DesktopBuildConfig.IS_DEBUG) { install(Logging) { logger = KermitHttpLogger - level = LogLevel.HEADERS + level = LogLevel.BODY } } } diff --git a/docs/kmp-status.md b/docs/kmp-status.md index bea19e8c3..1e6552437 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -1,6 +1,6 @@ # KMP Migration Status -> Last updated: 2026-04-13 +> Last updated: 2026-04-15 Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/). @@ -79,7 +79,7 @@ Working Compose Desktop application with: | Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | **9/10** | 26 modules validated (including feature:wifi-provision); native release installers automated | | DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils, desktop navigation graphs | +| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. SfppHasher, AddressUtils, formatString hex, and MetricFormatter edge cases newly covered. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils | ## Completion Estimates @@ -109,12 +109,14 @@ Based on the latest codebase investigation, the following steps are proposed to | Firmware KMP migration (pure Secure DFU) | ✅ Done | Native Nordic Secure DFU protocol reimplemented in pure KMP using Kable; desktop is first-class target | | Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta02`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | | JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime | -| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained | +| Expect/actual consolidation | ✅ Done | 10+ pairs eliminated (including `formatString`, `CommonUri`, `SfppHasher`); ~20 genuinely platform-specific retained (Parcelable, DateFormatter, Database, Location, Composable UI primitives) | | Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` | | **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. | | **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. | | Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants | | Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioTransport`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | +| URI unification | ✅ Done | `CommonUri` is a `typealias` to `com.eygraber.uri.Uri` (uri-kmp); `MeshtasticUri` wrapper deleted; bridge with `toAndroidUri()`/`toKmpUri()` | +| Utility commonization | ✅ Done | `formatString` → pure Kotlin parser in `commonMain`; `SfppHasher` and `CryptoCodec` → `Okio ByteString.sha256()`; `MetricFormatter` centralizes display strings (temperature, voltage, current, %, humidity, pressure, SNR, RSSI) | ## Navigation Parity Note diff --git a/docs/roadmap.md b/docs/roadmap.md index d97995bb4..8cff42c1f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -> Last updated: 2026-04-10 +> Last updated: 2026-04-15 Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). @@ -18,6 +18,8 @@ These items address structural gaps identified in the March 2026 architecture re | Auto-wire Desktop ViewModels via K2 Compiler (eliminate manual wiring) | Medium | Low | ✅ | | **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ | | **iOS CI gate (compile-only validation)** | High | Medium | ✅ | +| **Commonize utilities** (`formatString`, `SfppHasher`, `CryptoCodec`, `CommonUri`) | High | Medium | ✅ | +| **Centralize metric formatting** (`MetricFormatter`) | Medium | Low | ✅ | ## Active Work diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt index 57f06e225..8f5347e01 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import org.jetbrains.compose.resources.stringResource @@ -75,8 +77,11 @@ fun CurrentlyConnectedInfo( while (bleDevice.device.isConnected) { try { rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() } + } catch (_: TimeoutCancellationException) { + Logger.d { "RSSI read timed out" } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - // RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise Logger.d(e) { "Failed to read RSSI ${e.message}" } } delay(RSSI_DELAY.seconds) diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index 1647a5af7..3fa26d1cd 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.firmware import android.content.Context import co.touchlab.kermit.Logger +import com.eygraber.uri.toAndroidUri import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.head @@ -32,7 +33,6 @@ import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.ioDispatcher -import org.meshtastic.core.common.util.toPlatformUri import org.meshtastic.core.model.DeviceHardware import java.io.File import java.io.FileOutputStream @@ -188,7 +188,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien if (!tempDir.exists()) tempDir.mkdirs() try { - val platformUri = uri.toPlatformUri() as android.net.Uri + val platformUri = uri.toAndroidUri() val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null ZipInputStream(inputStream).use { zipInput -> var entry = zipInput.nextEntry @@ -225,9 +225,9 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) { file.toLocalFileOrNull()?.takeIf { it.exists() }?.length() - ?: context.contentResolver - .openAssetFileDescriptor(file.uri.toPlatformUri() as android.net.Uri, "r") - ?.use { descriptor -> descriptor.length.takeIf { it >= 0L } } + ?: context.contentResolver.openAssetFileDescriptor(file.uri.toAndroidUri(), "r")?.use { descriptor -> + descriptor.length.takeIf { it >= 0L } + } ?: 0L } @@ -242,16 +242,13 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien if (localFile != null && localFile.exists()) { localFile.readBytes() } else { - context.contentResolver.openInputStream(artifact.uri.toPlatformUri() as android.net.Uri)?.use { - it.readBytes() - } ?: throw IOException("Cannot open artifact: ${artifact.uri}") + context.contentResolver.openInputStream(artifact.uri.toAndroidUri())?.use { it.readBytes() } + ?: throw IOException("Cannot open artifact: ${artifact.uri}") } } override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = withContext(ioDispatcher) { - val inputStream = - context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri) - ?: return@withContext null + val inputStream = context.contentResolver.openInputStream(uri.toAndroidUri()) ?: return@withContext null val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin") tempFile.parentFile?.mkdirs() inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } @@ -282,10 +279,10 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien withContext(ioDispatcher) { val inputStream = source.toLocalFileOrNull()?.inputStream() - ?: context.contentResolver.openInputStream(source.uri.toPlatformUri() as android.net.Uri) + ?: context.contentResolver.openInputStream(source.uri.toAndroidUri()) ?: throw IOException("Cannot open source URI") val outputStream = - context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri) + context.contentResolver.openOutputStream(destinationUri.toAndroidUri()) ?: throw IOException("Cannot open content URI for writing") inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index eee6637af..1b5c0c803 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -163,9 +163,7 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView uri?.let { viewModel.startUpdateFromFile(it) } } - val saveFileLauncher = rememberSaveFileLauncher { meshtasticUri -> - viewModel.saveDfuFile(CommonUri.parse(meshtasticUri.uriString)) - } + val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) } val actions = remember(viewModel, onNavigateUp) { diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index b82e26432..dc1c45971 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.FirmwareReleaseType import org.meshtastic.core.datastore.BootloaderWarningDataSource @@ -123,9 +124,12 @@ class FirmwareUpdateViewModel( override fun onCleared() { super.onCleared() - // viewModelScope is already cancelled when onCleared() runs, so use a standalone scope - // for fire-and-forget cleanup of temporary firmware files. - kotlinx.coroutines.CoroutineScope(NonCancellable).launch { + // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a + // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a + // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope + // is cancelled concurrently. + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) + kotlinx.coroutines.GlobalScope.launch(NonCancellable) { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } } @@ -147,7 +151,7 @@ class FirmwareUpdateViewModel( updateJob = viewModelScope.launch { _state.value = FirmwareUpdateState.Checking - runCatching { + safeCatching { val ourNode = nodeRepository.myNodeInfo.value val address = radioPrefs.devAddr.value?.drop(1) if (address == null || ourNode == null) { @@ -200,7 +204,6 @@ class FirmwareUpdateViewModel( } } .onFailure { e -> - if (e is CancellationException) throw e Logger.e(e) { "Error checking for updates" } val unknownError = UiText.Resource(Res.string.firmware_update_unknown_error) _state.value = @@ -390,7 +393,7 @@ private suspend fun cleanupTemporaryFiles( fileHandler: FirmwareFileHandler, tempFirmwareFile: FirmwareArtifact?, ): FirmwareArtifact? { - runCatching { + safeCatching { tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) } fileHandler.cleanupAllTemporaryFiles() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt index 3bdb0f1d7..8565b3dcc 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt @@ -38,6 +38,7 @@ import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC +import org.meshtastic.core.common.util.safeCatching import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -78,7 +79,7 @@ class BleOtaTransport( } @Suppress("MagicNumber") - override suspend fun connect(): Result = runCatching { + override suspend fun connect(): Result = safeCatching { Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." } delay(REBOOT_DELAY) @@ -152,7 +153,7 @@ class BleOtaTransport( sizeBytes: Long, sha256Hash: String, onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val command = OtaCommand.StartOta(sizeBytes, sha256Hash) val packetsSent = sendCommand(command) @@ -189,7 +190,7 @@ class BleOtaTransport( data: ByteArray, chunkSize: Int, onProgress: suspend (Float) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val totalBytes = data.size var sentBytes = 0 @@ -215,7 +216,7 @@ class BleOtaTransport( if (nextSentBytes >= totalBytes && isLastPacketOfChunk) { sentBytes = nextSentBytes onProgress(1.0f) - return@runCatching Unit + return@safeCatching Unit } } is OtaResponse.Error -> { diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt index 97fced4c6..fa9966b66 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt @@ -45,7 +45,7 @@ internal fun calculateMacPlusOne(macAddress: String): String { if (parts.size != MAC_PARTS_COUNT) return macAddress val lastByte = parts[MAC_PARTS_COUNT - 1].toIntOrNull(HEX_RADIX) ?: return macAddress val incremented = ((lastByte + 1) and BYTE_MASK).toString(HEX_RADIX).uppercase().padStart(2, '0') - return parts.take(MAC_PARTS_COUNT - 1).joinToString(":") + ":" + incremented + return "${parts.take(MAC_PARTS_COUNT - 1).joinToString(":")}:$incremented" } /** diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt index 3694c4e6a..53e8ed977 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.common.util.safeCatching /** * WiFi/TCP transport implementation for ESP32 Unified OTA protocol. @@ -54,7 +55,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In /** Connect to the device via TCP using Ktor raw sockets. */ override suspend fun connect(): Result = withContext(ioDispatcher) { - runCatching { + safeCatching { Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" } val selector = SelectorManager(ioDispatcher) @@ -82,7 +83,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In sizeBytes: Long, sha256Hash: String, onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val command = OtaCommand.StartOta(sizeBytes, sha256Hash) sendCommand(command) @@ -116,7 +117,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In chunkSize: Int, onProgress: suspend (Float) -> Unit, ): Result = withContext(ioDispatcher) { - runCatching { + safeCatching { if (!isConnected) { throw OtaProtocolException.TransferFailed("Not connected") } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index 83d0deecc..10320e6e5 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -46,6 +46,7 @@ import org.meshtastic.core.ble.BleDevice import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.feature.firmware.ota.calculateMacPlusOne import org.meshtastic.feature.firmware.ota.scanForBleDevice import kotlin.time.Duration @@ -91,7 +92,7 @@ class SecureDfuTransport( * * The caller must have already released the mesh-service BLE connection before calling this. */ - suspend fun triggerButtonlessDfu(): Result = runCatching { + suspend fun triggerButtonlessDfu(): Result = safeCatching { Logger.i { "DFU: Scanning for device $address to trigger buttonless DFU..." } val device = @@ -152,7 +153,7 @@ class SecureDfuTransport( * Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling * notifications on the Control Point. */ - suspend fun connectToDfuMode(): Result = runCatching { + suspend fun connectToDfuMode(): Result = safeCatching { val dfuAddress = calculateMacPlusOne(address) val targetAddresses = setOf(address, dfuAddress) Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." } @@ -210,7 +211,7 @@ class SecureDfuTransport( * PRN is explicitly disabled (set to 0) for the init packet per the Nordic DFU library convention — the init packet * is small (<512 bytes, fits in a single object) and does not benefit from flow control. */ - suspend fun transferInitPacket(initPacket: ByteArray): Result = runCatching { + suspend fun transferInitPacket(initPacket: ByteArray): Result = safeCatching { Logger.i { "DFU: Transferring init packet (${initPacket.size} bytes)..." } setPrn(0) transferObjectWithRetry(DfuObjectType.COMMAND, initPacket, onProgress = null) @@ -231,12 +232,13 @@ class SecureDfuTransport( * @param firmware Raw bytes of the `.bin` file. * @param onProgress Callback receiving progress in [0.0, 1.0]. */ - suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = runCatching { - Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } - setPrn(PRN_INTERVAL) - transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) - Logger.i { "DFU: Firmware transferred and executed." } - } + suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = + safeCatching { + Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } + setPrn(PRN_INTERVAL) + transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) + Logger.i { "DFU: Firmware transferred and executed." } + } // --------------------------------------------------------------------------- // Abort & teardown diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index d8f7eeae0..1607ffa5d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.navigation.ChannelsRoute import org.meshtastic.core.navigation.ContactsRoute import org.meshtastic.core.navigation.NodesRoute @@ -35,7 +35,7 @@ fun AdaptiveContactsScreen( scrollToTopEvents: Flow, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, - onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, + onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, ) { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index ac6232ac2..7abaf6db6 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -61,7 +62,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState @@ -117,7 +118,7 @@ fun ContactsScreen( onNavigateToShare: () -> Unit, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, - onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, + onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, viewModel: ContactsViewModel, @@ -131,8 +132,8 @@ fun ContactsScreen( val scope = rememberCoroutineScope() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - var showMuteDialog by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf(false) } + var showMuteDialog by rememberSaveable { mutableStateOf(false) } + var showDeleteDialog by rememberSaveable { mutableStateOf(false) } // State for managing selected contacts val selectedContactKeys = remember { mutableStateListOf() } @@ -255,7 +256,7 @@ fun ContactsScreen( MeshtasticImportFAB( sharedContact = sharedContactRequested, onImport = { uriString -> - onHandleDeepLink(MeshtasticUri(uriString)) { + onHandleDeepLink(CommonUri.parse(uriString)) { scope.launch { showToast(Res.string.channel_invalid) } } }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 51f131bda..036fd3404 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.Base64Factory -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.formatUptime @@ -263,7 +263,7 @@ private fun SignalRow(node: Node) { if (node.snr != Float.MAX_VALUE) { InfoItem( label = stringResource(Res.string.snr), - value = formatString("%.1f dB", node.snr), + value = MetricFormatter.snr(node.snr), icon = MeshtasticIcons.Snr, modifier = Modifier.weight(1f), ) @@ -273,7 +273,7 @@ private fun SignalRow(node: Node) { if (node.rssi != Int.MAX_VALUE) { InfoItem( label = stringResource(Res.string.rssi), - value = formatString("%d dBm", node.rssi), + value = MetricFormatter.rssi(node.rssi), icon = MeshtasticIcons.Rssi, modifier = Modifier.weight(1f), ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index ad6714db7..22f4422ad 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -46,12 +46,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.isUnmessageableRole -import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_utilization @@ -260,14 +259,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col icon = MeshtasticIcons.ChannelUtilization, contentDescription = stringResource(Res.string.channel_utilization), label = stringResource(Res.string.channel_utilization), - text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization), + text = MetricFormatter.percent(thatNode.deviceMetrics.channel_utilization ?: 0f), contentColor = contentColor, ) IconInfo( icon = MeshtasticIcons.AirUtilization, contentDescription = stringResource(Res.string.air_utilization), label = stringResource(Res.string.air_utilization), - text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx), + text = MetricFormatter.percent(thatNode.deviceMetrics.air_util_tx ?: 0f), contentColor = contentColor, ) } @@ -320,31 +319,24 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C } if ((env.temperature ?: 0f) != 0f) { - val temp = - if (tempInFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f)) - } else { - formatString("%.1f°C", env.temperature ?: 0f) - } + val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit) items.add { TemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.relative_humidity ?: 0f) != 0f) { items.add { - HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor) + HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor) } } if ((env.barometric_pressure ?: 0f) != 0f) { items.add { - PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor) + PressureInfo( + pressure = MetricFormatter.pressure(env.barometric_pressure ?: 0f), + contentColor = contentColor, + ) } } if ((env.soil_temperature ?: 0f) != 0f) { - val temp = - if (tempInFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f)) - } else { - formatString("%.1f°C", env.soil_temperature ?: 0f) - } + val temp = MetricFormatter.temperature(env.soil_temperature ?: 0f, tempInFahrenheit) items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) { @@ -353,7 +345,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.voltage ?: 0f) != 0f) { items.add { PowerInfo( - value = formatString("%.2fV", env.voltage ?: 0f), + value = MetricFormatter.voltage(env.voltage ?: 0f), label = stringResource(Res.string.voltage), contentColor = contentColor, ) @@ -362,7 +354,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.current ?: 0f) != 0f) { items.add { PowerInfo( - value = formatString("%.1fmA", env.current ?: 0f), + value = MetricFormatter.current(env.current ?: 0f), label = stringResource(Res.string.current), contentColor = contentColor, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 5a156b836..2e8093ad8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -72,7 +72,7 @@ fun NodeListScreen( onNavigateToChannels: () -> Unit = {}, scrollToTopEvents: Flow? = null, activeNodeId: Int? = null, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val showToast = org.meshtastic.core.ui.util.rememberShowToastResource() val scope = rememberCoroutineScope() @@ -125,7 +125,7 @@ fun NodeListScreen( alignment = androidx.compose.ui.Alignment.BottomEnd, ), onImport = { uriString -> - onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) { + onHandleDeepLink(org.meshtastic.core.common.util.CommonUri.parse(uriString)) { scope.launch { showToast(Res.string.channel_invalid) } } }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 5725da604..1e749d22e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -55,6 +55,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType @@ -230,12 +232,13 @@ private fun DeviceMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> + val formatted = NumberFormatter.format(value, 1) when (color) { - batteryColor -> formatString(percentValueTemplate, batteryLabel, value) - voltageColor -> formatString(voltageValueTemplate, voltageLabel, value) - chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value) - airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value) - else -> formatString(numericValueTemplate, value) + batteryColor -> formatString(percentValueTemplate, batteryLabel, formatted) + voltageColor -> formatString(voltageValueTemplate, voltageLabel, formatted) + chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, formatted) + airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, formatted) + else -> formatString(numericValueTemplate, formatted) } }, ) @@ -337,7 +340,7 @@ private fun DeviceMetricsChart( if (leftLayer != null) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = batteryColor), - valueFormatter = { _, value, _ -> formatString("%.0f%%", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.percent(value.toFloat(), 0) }, ) } else { null @@ -346,7 +349,7 @@ private fun DeviceMetricsChart( if (rightLayer != null) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, + valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" }, ) } else { null @@ -441,7 +444,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick formatString( percentValueTemplate, channelUtilizationLabel, - deviceMetrics.channel_utilization ?: 0f, + NumberFormatter.format(deviceMetrics.channel_utilization ?: 0f, 1), ), ) Spacer(Modifier.width(12.dp)) @@ -453,7 +456,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick formatString( percentValueTemplate, airUtilizationLabel, - deviceMetrics.air_util_tx ?: 0f, + NumberFormatter.format(deviceMetrics.air_util_tx ?: 0f, 1), ), ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 4967e65d5..10a3fe427 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -37,7 +37,7 @@ import okio.ByteString.Companion.decodeBase64 import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers @@ -333,7 +333,7 @@ open class MetricsViewModel( * epoch-seconds timestamp extracted by [epochSeconds]. */ private fun exportCsv( - uri: MeshtasticUri, + uri: CommonUri, header: String, rows: List, epochSeconds: (T) -> Long, @@ -351,11 +351,10 @@ open class MetricsViewModel( } } - fun savePositionCSV(uri: MeshtasticUri, data: List) { + fun savePositionCSV(uri: CommonUri, data: List) { exportCsv( uri = uri, - header = - "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\"," + "\"satsInView\",\"speed\",\"heading\"\n", + header = "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n", rows = data, epochSeconds = { it.time.toLong() }, ) { pos -> @@ -366,7 +365,7 @@ open class MetricsViewModel( } } - fun saveDeviceMetricsCSV(uri: MeshtasticUri, data: List) { + fun saveDeviceMetricsCSV(uri: CommonUri, data: List) { exportCsv( uri = uri, header = @@ -382,7 +381,7 @@ open class MetricsViewModel( } } - fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List) { + fun saveEnvironmentMetricsCSV(uri: CommonUri, data: List) { val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" } exportCsv( uri = uri, @@ -405,7 +404,7 @@ open class MetricsViewModel( } } - fun saveSignalMetricsCSV(uri: MeshtasticUri, data: List) { + fun saveSignalMetricsCSV(uri: CommonUri, data: List) { exportCsv( uri = uri, header = "\"date\",\"time\",\"rssi\",\"snr\"\n", @@ -416,7 +415,7 @@ open class MetricsViewModel( } } - fun savePowerMetricsCSV(uri: MeshtasticUri, data: List) { + fun savePowerMetricsCSV(uri: CommonUri, data: List) { exportCsv( uri = uri, header = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index c815f6622..5e7560bcb 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -54,7 +54,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res @@ -194,9 +195,9 @@ private fun PowerMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> when (color) { - currentColor -> formatString("Current: %.0f mA", value) - voltageColor -> formatString("Voltage: %.1f V", value) - else -> formatString("%.1f", value) + currentColor -> "Current: ${MetricFormatter.current(value.toFloat(), 0)}" + voltageColor -> "Voltage: ${NumberFormatter.format(value.toFloat(), 1)} V" + else -> NumberFormatter.format(value.toFloat(), 1) } }, ) @@ -256,7 +257,7 @@ private fun PowerMetricsChart( if (currentData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = currentColor), - valueFormatter = { _, value, _ -> formatString("%.0f mA", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.current(value.toFloat(), 0) }, ) } else { null @@ -265,7 +266,7 @@ private fun PowerMetricsChart( if (voltageData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, + valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" }, ) } else { null @@ -369,8 +370,8 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current style = TextStyle(fontWeight = FontWeight.Bold), fontSize = MaterialTheme.typography.labelLarge.fontSize, ) - MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage)) - MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current)) + MetricValueRow(color = PowerMetric.VOLTAGE.color, text = MetricFormatter.voltage(voltage)) + MetricValueRow(color = PowerMetric.CURRENT.color, text = MetricFormatter.current(current)) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index e8b184427..4931d8c59 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -47,7 +47,7 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res @@ -157,9 +157,9 @@ private fun SignalMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> if (color == rssiColor) { - formatString("RSSI: %.0f dBm", value) + "RSSI: ${MetricFormatter.rssi(value.toInt())}" } else { - formatString("SNR: %.1f dB", value) + "SNR: ${MetricFormatter.snr(value.toFloat())}" } }, ) @@ -189,7 +189,7 @@ private fun SignalMetricsChart( if (rssiData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = rssiColor), - valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.rssi(value.toInt()) }, ) } else { null @@ -198,7 +198,7 @@ private fun SignalMetricsChart( if (snrData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = snrColor), - valueFormatter = { _, value, _ -> formatString("%.1f dB", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.snr(value.toFloat()) }, ) } else { null @@ -234,15 +234,9 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli /* SNR and RSSI */ Row(verticalAlignment = Alignment.CenterVertically) { - MetricValueRow( - color = SignalMetric.RSSI.color, - text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()), - ) + MetricValueRow(color = SignalMetric.RSSI.color, text = MetricFormatter.rssi(meshPacket.rx_rssi)) Spacer(Modifier.width(12.dp)) - MetricValueRow( - color = SignalMetric.SNR.color, - text = formatString("%.1f dB", meshPacket.rx_snr), - ) + MetricValueRow(color = SignalMetric.SNR.color, text = MetricFormatter.snr(meshPacket.rx_snr)) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 163bdb4f9..d4d8c0d17 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -56,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.fullRouteDiscovery @@ -113,7 +114,7 @@ fun TracerouteLogScreen( val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest) val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us) - val durationTemplate = stringResource(Res.string.traceroute_duration, "%SECS%") + val durationFormatStr = stringResource(Res.string.traceroute_duration) val threshold = timeFrame.timeThreshold() val filteredRequests = @@ -176,7 +177,7 @@ fun TracerouteLogScreen( getUsername = ::getUsername, headerTowards = headerTowardsStr, headerBack = headerBackStr, - durationTemplate = durationTemplate, + durationTemplate = durationFormatStr, statusGreen = statusGreen, statusYellow = statusYellow, statusOrange = statusOrange, @@ -335,7 +336,7 @@ private fun showTracerouteDetail( statusYellow = statusYellow, statusOrange = statusOrange, ) - val durationText = durationTemplate.replace("%SECS%", formatString("%.1f", seconds)) + val durationText = formatString(durationTemplate, NumberFormatter.format(seconds, 1)) buildAnnotatedString { append(annotatedBase) append("\n\n$durationText") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index cca1b67bf..dc72fac5e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -31,7 +31,7 @@ import org.meshtastic.feature.node.list.NodeListViewModel fun AdaptiveNodeListScreen( backStack: NavBackStack, scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val nodeListViewModel: NodeListViewModel = koinViewModel() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 778c8b220..233942f00 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -73,7 +73,7 @@ import kotlin.reflect.KClass fun EntryProviderScope.nodesGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( @@ -99,7 +99,7 @@ fun EntryProviderScope.nodesGraph( fun EntryProviderScope.nodeDetailGraph( backStack: NavBackStack, scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { entry(metadata = { ListDetailSceneStrategy.listPane() }) { args -> AdaptiveNodeListScreen( diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index 961a34dd6..956c20175 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import okio.Buffer import okio.BufferedSink -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogRepository @@ -210,7 +210,7 @@ class MetricsViewModelTest { awaitItem() // Empty awaitItem() // with position - val uri = MeshtasticUri("content://test") + val uri = CommonUri.parse("content://test") vm.savePositionCSV(uri, listOf(testPosition)) runCurrent() diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index eeab3b873..82cd4b7be 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -30,15 +30,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.eygraber.uri.toKmpUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.toDate import org.meshtastic.core.common.util.toInstant -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.WifiProvisionRoute @@ -89,14 +90,14 @@ fun SettingsScreen( val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var deviceProfile by remember { mutableStateOf(null) } - var showEditDeviceProfileDialog by remember { mutableStateOf(false) } + var showEditDeviceProfileDialog by rememberSaveable { mutableStateOf(false) } val importConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { showEditDeviceProfileDialog = true it.data?.data?.let { uri -> - viewModel.importProfile(uri.toMeshtasticUri()) { profile -> deviceProfile = profile } + viewModel.importProfile(uri.toKmpUri()) { profile -> deviceProfile = profile } } } } @@ -104,7 +105,7 @@ fun SettingsScreen( val exportConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportProfile(uri.toMeshtasticUri(), deviceProfile!!) } + it.data?.data?.let { uri -> viewModel.exportProfile(uri.toKmpUri(), deviceProfile!!) } } } @@ -143,12 +144,12 @@ fun SettingsScreen( ) } - var showLanguagePickerDialog by remember { mutableStateOf(false) } + var showLanguagePickerDialog by rememberSaveable { mutableStateOf(false) } if (showLanguagePickerDialog) { LanguagePickerDialog { showLanguagePickerDialog = false } } - var showThemePickerDialog by remember { mutableStateOf(false) } + var showThemePickerDialog by rememberSaveable { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -249,7 +250,7 @@ fun SettingsScreen( cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value, onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) }, nodeShortName = ourNode?.user?.short_name ?: "", - onExportData = { settingsViewModel.saveDataCsv(it.toMeshtasticUri()) }, + onExportData = { settingsViewModel.saveDataCsv(it.toKmpUri()) }, ) AppInfoSection( diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt index 96e6890b2..15cd0e11d 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt @@ -30,9 +30,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.eygraber.uri.toKmpUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.export_keys import org.meshtastic.core.resources.export_keys_confirmation @@ -54,7 +54,7 @@ actual fun ExportSecurityConfigButton( val exportConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), securityConfig) } + it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toKmpUri(), securityConfig) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index d4b39565b..ddad8296e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -28,7 +28,7 @@ import okio.BufferedSink import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase @@ -187,7 +187,7 @@ class SettingsViewModel( * @param uri The destination URI for the CSV file. * @param filterPortnum If provided, only packets with this port number will be exported. */ - fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) { + fun saveDataCsv(uri: CommonUri, filterPortnum: Int? = null) { safeLaunch(tag = "saveDataCsv") { fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt index 6ed8cb427..1600ce947 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt @@ -35,7 +35,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -158,7 +158,7 @@ fun DebugSearchState( onExportLogs: (() -> Unit)? = null, ) { val colorScheme = MaterialTheme.colorScheme - var customFilterText by remember { mutableStateOf("") } + var customFilterText by rememberSaveable { mutableStateOf("") } Column(modifier = modifier.background(color = colorScheme.background.copy(alpha = 1.0f)).padding(8.dp)) { Row( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 682e0e8c3..f04ade2e8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -61,15 +61,6 @@ import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint -data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String) - -data class SearchState( - val searchText: String = "", - val currentMatchIndex: Int = -1, - val allMatches: List = emptyList(), - val hasMatches: Boolean = false, -) - enum class FilterMode { AND, OR, @@ -387,17 +378,15 @@ class DebugViewModel( val nodeIdStr = nodeId.toUInt().toString() // Only match if whitespace before and after val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""") - regex.find(this)?.let { _ -> - regex.findAll(this).toList().asReversed().forEach { - val idx = it.range.last + 1 - insert(idx, " (${nodeId.toHex(8)})") - } - return true + if (!regex.containsMatchIn(this)) return false + regex.findAll(this).toList().asReversed().forEach { + val idx = it.range.last + 1 + insert(idx, " (${nodeId.toHex(8)})") } - return false + return true } - private fun Int.toHex(length: Int): String = "!" + this.toUInt().toString(16).padStart(length, '0') + private fun Int.toHex(length: Int): String = "!${this.toUInt().toString(16).padStart(length, '0')}" fun requestDeleteAllLogs() { alertManager.showAlert( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 54f0f7100..1ee791620 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.settings.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -80,7 +79,7 @@ fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewMod .lastOrNull { it is SettingsRoute.SettingsGraph } ?.let { (it as SettingsRoute.SettingsGraph).destNum } } - SideEffect { viewModel.initDestNum(destNum) } + LaunchedEffect(destNum) { viewModel.initDestNum(destNum) } return viewModel } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 4b8427c87..7a946b78b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.update import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase @@ -384,7 +384,7 @@ open class RadioConfigViewModel( safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) } } - fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) { + fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) { safeLaunch(tag = "importProfile") { var profile: DeviceProfile? = null fileService.read(uri) { source -> @@ -394,7 +394,7 @@ open class RadioConfigViewModel( } } - fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) { + fun exportProfile(uri: CommonUri, profile: DeviceProfile) { safeLaunch(tag = "exportProfile") { fileService.write(uri) { sink -> exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } @@ -402,7 +402,7 @@ open class RadioConfigViewModel( } } - fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) { + fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) { safeLaunch(tag = "exportSecurityConfig") { fileService.write(uri) { sink -> exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 650898747..885e64219 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -113,9 +113,9 @@ private fun ChannelConfigScreen( onPositiveClicked: (List) -> Unit, ) { val primarySettings = settingsList.getOrNull(0) ?: return - val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) } - val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) } - val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) } + val modemPresetName = remember(loraConfig) { Channel(loraConfig = loraConfig).name } + val primaryChannel = remember(loraConfig) { Channel(primarySettings, loraConfig) } + val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) } val focusManager = LocalFocusManager.current val settingsListInput = @@ -141,7 +141,7 @@ private fun ChannelConfigScreen( if (showEditChannelDialog != null) { val index = showEditChannelDialog ?: return EditChannelDialog( - channelSettings = with(settingsListInput) { if (size > index) get(index) else ChannelSettings() }, + channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(), modemPresetName = modemPresetName, onAddClick = { if (settingsListInput.size > index) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt index 0a943a70b..8c7386db5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt @@ -124,7 +124,7 @@ fun ChannelScreen( val modemPresetName by remember(channels) { mutableStateOf(Channel(loraConfig = channels.lora_config ?: Config.LoRaConfig()).name) } - var showResetDialog by remember { mutableStateOf(false) } + var showResetDialog by rememberSaveable { mutableStateOf(false) } var shouldAddChannelsState by remember { mutableStateOf(true) } @@ -211,7 +211,7 @@ fun ChannelScreen( requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) } - var showShareDialog by remember { mutableStateOf(false) } + var showShareDialog by rememberSaveable { mutableStateOf(false) } if (showShareDialog) { ChannelShareDialog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index e4f91ece6..f57306799 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -71,7 +71,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val primarySettings = state.channelList.getOrNull(0) ?: return val formState = rememberConfigState(initialValue = loraConfig) - val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) } + val primaryChannel = remember(formState.value) { Channel(primarySettings, formState.value) } val focusManager = LocalFocusManager.current RadioConfigScreenList( diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt index 03330dc3e..1723e6df6 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt @@ -36,6 +36,7 @@ import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleConnectionState import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BleWriteType +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.feature.wifiprovision.NymeaBleConstants import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT_HIDDEN @@ -88,7 +89,7 @@ class NymeaWifiService( * @return The discovered device's advertised name on success. * @throws IllegalStateException if no device is found within [SCAN_TIMEOUT]. */ - suspend fun connect(address: String? = null): Result = runCatching { + suspend fun connect(address: String? = null): Result = safeCatching { Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" } val device = @@ -138,7 +139,7 @@ class NymeaWifiService( * * Sends: CMD_SCAN (4), waits for ack, then CMD_GET_NETWORKS (0). */ - suspend fun scanNetworks(): Result> = runCatching { + suspend fun scanNetworks(): Result> = safeCatching { // Trigger scan sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN))) val scanAck = NymeaJson.decodeFromString(waitForResponse()) @@ -180,7 +181,7 @@ class NymeaWifiService( NymeaConnectCommand(command = cmd, params = NymeaConnectParams(ssid = ssid, password = password)), ) - return runCatching { + return safeCatching { sendCommand(json) val response = NymeaJson.decodeFromString(waitForResponse()) if (response.responseCode == RESPONSE_SUCCESS) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c9978463..c3b4c24ca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,7 @@ firebase-crashlytics-gradle = "3.0.7" google-services-gradle = "4.4.4" markdownRenderer = "0.40.2" okio = "3.17.0" +uri-kmp = "0.0.21" osmdroid-android = "6.1.20" spotless = "8.4.0" wire = "6.2.0" @@ -104,7 +105,6 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi # lifecycle-runtime-ktx dropped: KTX extensions merged into lifecycle-runtime since 2.8.0; # use jetbrains-lifecycle-runtime (JB KMP fork) instead. androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } -androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" } # JetBrains KMP lifecycle (use in commonMain and androidMain) jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" } @@ -228,6 +228,7 @@ kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref = jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +uri-kmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" } osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" } osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" } osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" } From a2763bdfebb0885a229057f7a812d8b5775b7400 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:20:33 -0500 Subject: [PATCH 067/114] fix(charts): apply Vico 3.1.0 best-practice audit fixes (#5138) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../feature/node/metrics/BaseMetricChart.kt | 75 +++++++++++-------- .../feature/node/metrics/ChartStyling.kt | 42 +++++++---- .../feature/node/metrics/DeviceMetrics.kt | 3 +- .../feature/node/metrics/EnvironmentCharts.kt | 18 +++-- .../feature/node/metrics/PaxMetrics.kt | 17 ++++- .../feature/node/metrics/TracerouteChart.kt | 17 ++++- 6 files changed, 110 insertions(+), 62 deletions(-) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index 8f65bf6d8..a425e272d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.IconToggleButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,6 +80,9 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.icon.Save +/** Minimum x-step (in seconds) to prevent the default GCD from producing a value of 1 with irregular timestamps. */ +private const val MIN_X_STEP_SECONDS = 60.0 + /** * A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point * selection synchronization. @@ -100,43 +104,50 @@ fun GenericMetricChart( onPointSelected: ((Double) -> Unit)? = null, vicoScrollState: VicoScrollState = rememberVicoScrollState(), ) { - // Hoist zoom state above rememberCartesianChart so that the variable slot count - // from the vararg layers spread does not shift this remember call during recomposition - // (toggling legend chips changes the layer count, which corrupts the slot table). - val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content) + // Key on layer count so Compose rebuilds the entire subtree when legend chip toggles + // add/remove layers. rememberCartesianChart uses vararg internally, so changing the + // argument count without a key corrupts the slot table. + key(layers.size) { + val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content) - val markerVisibilityListener = - remember(onPointSelected) { - object : CartesianMarkerVisibilityListener { - override fun onShown(marker: CartesianMarker, targets: List) { - targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } - } + val markerVisibilityListener = + remember(onPointSelected) { + object : CartesianMarkerVisibilityListener { + override fun onShown(marker: CartesianMarker, targets: List) { + targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + } - override fun onUpdated(marker: CartesianMarker, targets: List) { - targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + override fun onUpdated(marker: CartesianMarker, targets: List) { + targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + } } } - } - CartesianChartHost( - chart = - @Suppress("SpreadOperator") - rememberCartesianChart( - *layers.toTypedArray(), - startAxis = startAxis, - endAxis = endAxis, - bottomAxis = bottomAxis, - marker = marker, - markerVisibilityListener = markerVisibilityListener, - persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null }, - fadingEdges = rememberFadingEdges(), - decorations = decorations, - ), - modelProducer = modelProducer, - modifier = modifier, - scrollState = vicoScrollState, - zoomState = zoomState, - ) + CartesianChartHost( + chart = + @Suppress("SpreadOperator") + rememberCartesianChart( + *layers.toTypedArray(), + startAxis = startAxis, + endAxis = endAxis, + bottomAxis = bottomAxis, + marker = marker, + markerVisibilityListener = markerVisibilityListener, + persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null }, + fadingEdges = rememberFadingEdges(), + decorations = decorations, + // Telemetry timestamps arrive at irregular intervals. Without an explicit + // x-step, Vico computes the GCD of consecutive x-value differences which can + // be as small as 1 second, making the chart logically enormous. A 60-second + // floor keeps the internal slot count reasonable for any practical interval. + getXStep = { model -> maxOf(model.getXDeltaGcd(), MIN_X_STEP_SECONDS) }, + ), + modelProducer = modelProducer, + modifier = modifier, + scrollState = vicoScrollState, + zoomState = zoomState, + ) + } } /** diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt index c1cf0e04e..da8b16e47 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt @@ -57,7 +57,7 @@ import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent * **Design principles** (per [design#53](https://github.com/meshtastic/design/issues/53)): * - Default to thin lines **without** point markers to avoid clutter on dense timeseries. * - Show a single dot only at the marker/cursor position (handled by [rememberMarker]). - * - Use `Interpolator.catmullRom()` for smooth curves that pass through every data point. + * - Use `Interpolator.cubic()` for smooth monotone curves that won't overshoot between sparse points. * - Reserve bold lines for the single most-important series; use subtle/gradient fills for secondary data. */ @Suppress("TooManyFunctions") @@ -73,15 +73,21 @@ object ChartStyling { * * @param lineColor The color of the line * @param lineWidth Width of the line in dp + * @param interpolator The line interpolation strategy. Defaults to monotone + * [cubic][LineCartesianLayer.Interpolator.cubic] which won't overshoot between sparse data points (unlike + * catmull-rom). Use [Sharp][LineCartesianLayer.Interpolator.Sharp] for discrete/integer metrics like hop counts. * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createStyledLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line = - LineCartesianLayer.rememberLine( - fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), - stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), - interpolator = LineCartesianLayer.Interpolator.catmullRom(), - ) + fun createStyledLine( + lineColor: Color, + lineWidth: Float = MEDIUM_LINE_WIDTH_DP, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( + fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), + stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), + interpolator = interpolator, + ) /** * Creates a line with a gradient area fill effect. Ideal for emphasising a single series or showing magnitude. The @@ -92,14 +98,18 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createGradientLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line { + fun createGradientLine( + lineColor: Color, + lineWidth: Float = MEDIUM_LINE_WIDTH_DP, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line { val gradientBrush = Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.05f))) return LineCartesianLayer.rememberLine( fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), areaFill = LineCartesianLayer.AreaFill.single(Fill(gradientBrush)), stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), - interpolator = LineCartesianLayer.Interpolator.catmullRom(), + interpolator = interpolator, ) } @@ -110,8 +120,11 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createBoldLine(lineColor: Color): LineCartesianLayer.Line = - createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP) + fun createBoldLine( + lineColor: Color, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP, interpolator = interpolator) /** * Creates a subtle line suitable for secondary metrics that should not dominate the chart. @@ -131,7 +144,10 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createDashedLine(lineColor: Color): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( + fun createDashedLine( + lineColor: Color, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), stroke = LineCartesianLayer.LineStroke.Dashed( @@ -139,7 +155,7 @@ object ChartStyling { dashLength = 6.dp, gapLength = 3.dp, ), - interpolator = LineCartesianLayer.Interpolator.catmullRom(), + interpolator = interpolator, ) /** diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 1e749d22e..609048a92 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -307,12 +307,13 @@ private fun DeviceMetricsChart( } } + val percentRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0) } val leftLayer = rememberConditionalLayer( hasData = leftLayerSeriesStyles.isNotEmpty(), lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles), verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0), + rangeProvider = percentRangeProvider, ) val rightLayer = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index 0f809ef81..5029729ca 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -158,11 +158,11 @@ fun EnvironmentMetricsChart( graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0] } - // Legend toggle state: tracks indices into allLegendData that are hidden - var hiddenIndices by remember { mutableStateOf(emptySet()) } - val hiddenMetrics = - remember(hiddenIndices, allLegendData) { - hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet() + // Track hidden metrics by key (not index) so toggling survives changes in allLegendData ordering. + var hiddenMetrics by remember { mutableStateOf(emptySet()) } + val hiddenIndices = + remember(hiddenMetrics, allLegendData) { + allLegendData.indices.filter { (allLegendData[it].metricKey as? Environment) in hiddenMetrics }.toSet() } val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) } @@ -233,6 +233,7 @@ fun EnvironmentMetricsChart( }, ) + val pressureRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0) } val layers = mutableListOf() if (showPressure && pressureData.isNotEmpty()) { layers.add( @@ -244,7 +245,7 @@ fun EnvironmentMetricsChart( verticalAxisPosition = Axis.Position.Vertical.Start, // Fixed range per Oscar's UX guidance: barometric pressure should NOT autoscale, // otherwise trends (storms) are invisible. 700-1200 hPa covers sea-level to altitude. - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0), + rangeProvider = pressureRangeProvider, ), ) } @@ -254,7 +255,7 @@ fun EnvironmentMetricsChart( when (metric) { Environment.RADIATION, Environment.WIND_SPEED, - -> CartesianLayerRangeProvider.fixed(minY = 0.0) + -> CartesianLayerRangeProvider.auto() else -> null } val lineStyle = @@ -310,7 +311,8 @@ fun EnvironmentMetricsChart( modifier = Modifier.padding(top = 0.dp), hiddenSet = hiddenIndices, onToggle = { index -> - hiddenIndices = if (index in hiddenIndices) hiddenIndices - index else hiddenIndices + index + val metric = allLegendData.getOrNull(index)?.metricKey as? Environment ?: return@Legend + hiddenMetrics = if (metric in hiddenMetrics) hiddenMetrics - metric else hiddenMetrics + metric }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index 598cd5ca9..b3b0b36e0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -141,11 +141,20 @@ private fun PaxMetricsChart( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine(lineColor = bleColor), - ChartStyling.createGradientLine(lineColor = wifiColor), - ChartStyling.createBoldLine(lineColor = paxColor), + ChartStyling.createGradientLine( + lineColor = bleColor, + interpolator = LineCartesianLayer.Interpolator.Sharp, + ), + ChartStyling.createGradientLine( + lineColor = wifiColor, + interpolator = LineCartesianLayer.Interpolator.Sharp, + ), + ChartStyling.createBoldLine( + lineColor = paxColor, + interpolator = LineCartesianLayer.Interpolator.Sharp, + ), ), - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + rangeProvider = CartesianLayerRangeProvider.auto(), ), ), startAxis = VerticalAxis.rememberStart(label = axisLabel), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt index c1e5e69fe..c27f111d1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt @@ -189,17 +189,26 @@ internal fun TracerouteMetricsChart( val forwardLayer = rememberConditionalLayer( hasData = forwardData.isNotEmpty(), - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)), + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createStyledLine( + forwardColor, + interpolator = LineCartesianLayer.Interpolator.Sharp, + ), + ), verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + rangeProvider = CartesianLayerRangeProvider.auto(), ) val returnLayer = rememberConditionalLayer( hasData = returnData.isNotEmpty(), - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)), + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createDashedLine(returnColor, interpolator = LineCartesianLayer.Interpolator.Sharp), + ), verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + rangeProvider = CartesianLayerRangeProvider.auto(), ) val rttLayer = From 401f59489a733330f2b1b9a27ebbbb1661bc34a2 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:10:23 -0500 Subject: [PATCH 068/114] chore: remove deprecated mesh_service_example module (#5055) --- .github/workflows/pull-request.yml | 3 +- .skills/project-overview/SKILL.md | 1 - .skills/testing-ci/SKILL.md | 2 +- codecov.yml | 4 - .../core/service/ServiceBroadcasts.kt | 2 +- docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md | 4 +- gradle/libs.versions.toml | 1 - mesh_service_example/README.md | 20 - mesh_service_example/build.gradle.kts | 54 -- mesh_service_example/detekt-baseline.xml | 5 - mesh_service_example/proguard-rules.pro | 21 - .../src/main/AndroidManifest.xml | 33 - .../meshserviceexample/MainActivity.kt | 187 ------ .../android/meshserviceexample/MainScreen.kt | 585 ------------------ .../MeshServiceViewModel.kt | 363 ----------- .../ic_launcher_background.xml | 170 ----- .../ic_launcher_foreground.xml | 30 - .../main/res/mipmap-anydpi/ic_launcher.xml | 6 - .../src/main/res/values-ar-rSA/strings.xml | 21 - .../src/main/res/values-b+sr+Latn/strings.xml | 21 - .../src/main/res/values-be-rBY/strings.xml | 21 - .../src/main/res/values-bg-rBG/strings.xml | 21 - .../src/main/res/values-ca-rES/strings.xml | 21 - .../src/main/res/values-cs-rCZ/strings.xml | 21 - .../src/main/res/values-de-rDE/strings.xml | 21 - .../src/main/res/values-el-rGR/strings.xml | 21 - .../src/main/res/values-es-rES/strings.xml | 21 - .../src/main/res/values-et-rEE/strings.xml | 21 - .../src/main/res/values-fi-rFI/strings.xml | 21 - .../src/main/res/values-fr-rFR/strings.xml | 21 - .../src/main/res/values-ga-rIE/strings.xml | 21 - .../src/main/res/values-gl-rES/strings.xml | 21 - .../src/main/res/values-hr-rHR/strings.xml | 21 - .../src/main/res/values-ht-rHT/strings.xml | 21 - .../src/main/res/values-hu-rHU/strings.xml | 21 - .../src/main/res/values-is-rIS/strings.xml | 21 - .../src/main/res/values-it-rIT/strings.xml | 21 - .../src/main/res/values-iw-rIL/strings.xml | 21 - .../src/main/res/values-ja-rJP/strings.xml | 21 - .../src/main/res/values-ko-rKR/strings.xml | 21 - .../src/main/res/values-lt-rLT/strings.xml | 21 - .../src/main/res/values-nl-rNL/strings.xml | 21 - .../src/main/res/values-no-rNO/strings.xml | 21 - .../src/main/res/values-pl-rPL/strings.xml | 21 - .../src/main/res/values-pt-rBR/strings.xml | 21 - .../src/main/res/values-pt-rPT/strings.xml | 21 - .../src/main/res/values-ro-rRO/strings.xml | 21 - .../src/main/res/values-ru-rRU/strings.xml | 21 - .../src/main/res/values-sk-rSK/strings.xml | 21 - .../src/main/res/values-sl-rSI/strings.xml | 21 - .../src/main/res/values-sq-rAL/strings.xml | 21 - .../src/main/res/values-srp/strings.xml | 21 - .../src/main/res/values-sv-rSE/strings.xml | 21 - .../src/main/res/values-tr-rTR/strings.xml | 21 - .../src/main/res/values-uk-rUA/strings.xml | 21 - .../src/main/res/values-zh-rCN/strings.xml | 21 - .../src/main/res/values-zh-rTW/strings.xml | 21 - .../src/main/res/values/colors.xml | 2 - .../src/main/res/values/strings.xml | 21 - .../src/main/res/values/themes.xml | 9 - .../src/main/res/xml/backup_rules.xml | 13 - .../main/res/xml/data_extraction_rules.xml | 19 - settings.gradle.kts | 1 - 63 files changed, 5 insertions(+), 2370 deletions(-) delete mode 100644 mesh_service_example/README.md delete mode 100644 mesh_service_example/build.gradle.kts delete mode 100644 mesh_service_example/detekt-baseline.xml delete mode 100644 mesh_service_example/proguard-rules.pro delete mode 100644 mesh_service_example/src/main/AndroidManifest.xml delete mode 100644 mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt delete mode 100644 mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt delete mode 100644 mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt delete mode 100644 mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml delete mode 100644 mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml delete mode 100644 mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml delete mode 100644 mesh_service_example/src/main/res/values-ar-rSA/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-be-rBY/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-bg-rBG/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-ca-rES/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-cs-rCZ/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-de-rDE/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-el-rGR/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-es-rES/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-et-rEE/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-fi-rFI/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-fr-rFR/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-ga-rIE/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-gl-rES/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-hr-rHR/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-ht-rHT/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-hu-rHU/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-is-rIS/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-it-rIT/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-iw-rIL/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-ja-rJP/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-ko-rKR/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-lt-rLT/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-nl-rNL/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-no-rNO/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-pl-rPL/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-pt-rBR/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-pt-rPT/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-ro-rRO/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-ru-rRU/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-sk-rSK/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-sl-rSI/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-sq-rAL/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-srp/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-sv-rSE/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-tr-rTR/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-uk-rUA/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-zh-rCN/strings.xml delete mode 100644 mesh_service_example/src/main/res/values-zh-rTW/strings.xml delete mode 100644 mesh_service_example/src/main/res/values/colors.xml delete mode 100644 mesh_service_example/src/main/res/values/strings.xml delete mode 100644 mesh_service_example/src/main/res/values/themes.xml delete mode 100644 mesh_service_example/src/main/res/xml/backup_rules.xml delete mode 100644 mesh_service_example/src/main/res/xml/data_extraction_rules.xml diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 209d6e35c..d450711ce 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -70,8 +70,7 @@ jobs: } allowed_extra_roots = {'baselineprofile'} - excluded_roots = {'mesh_service_example'} - expected_roots = (module_roots | allowed_extra_roots) - excluded_roots + expected_roots = module_roots | allowed_extra_roots filter_paths = { path.split('/')[0] diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md index 291cff488..2224fa7ad 100644 --- a/.skills/project-overview/SKILL.md +++ b/.skills/project-overview/SKILL.md @@ -39,7 +39,6 @@ Module directory, namespacing conventions, environment setup, and troubleshootin | `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. | | `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | | `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. | -| `mesh_service_example/` | **DEPRECATED.** Legacy sample app; not yet removed. See `core/api/README.md` for the current integration guide. | ## Namespacing - **Standard:** Use the `org.meshtastic.*` namespace for all code. diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 2c20258c1..1c8b7b901 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -48,7 +48,7 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p 2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`): - `shard-core`: `allTests` for all `core:*` KMP modules. - `shard-feature`: `allTests` for all `feature:*` KMP modules. - - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`). + - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`). Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. 3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`). diff --git a/codecov.yml b/codecov.yml index 6e0989227..7f77510ff 100644 --- a/codecov.yml +++ b/codecov.yml @@ -57,10 +57,6 @@ component_management: name: Desktop paths: - desktop/** - - component_id: example - name: Example - paths: - - mesh_service_example/** ignore: - "**/build/**" diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt index 57408cff1..22bacf43a 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt @@ -133,7 +133,7 @@ class ServiceBroadcasts(private val context: Context, private val serviceReposit explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED)) } - // Restore legacy action for other consumers (e.g. mesh_service_example) + // Restore legacy action for other consumers (e.g. ATAK plugins) val legacyIntent = Intent(ACTION_CONNECTION_CHANGED).apply { putExtra(EXTRA_CONNECTED, stateStr) diff --git a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md index 5898f7f94..d3dd5ad93 100644 --- a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md +++ b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md @@ -121,8 +121,8 @@ kotlin { ``` **What the plugin provides automatically:** -- `commonMain`: `compose-multiplatform-material3`, `compose-multiplatform-materialIconsExtended`, `jetbrains-lifecycle-viewmodel-compose`, `koin-compose-viewmodel`, `kermit` -- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-material-iconsExtended`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview` +- `commonMain`: `compose-multiplatform-material3`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-runtime-compose`, `koin-compose-viewmodel`, `kermit` +- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview` - `commonTest`: `core:testing` **Why:** Eliminates ~15 duplicate dependency declarations per feature module (modelled after Now in Android's `AndroidFeatureImplConventionPlugin`). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3b4c24ca..d1051dc2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -122,7 +122,6 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) -androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose-material" } # Only used by deprecated mesh_service_example — remove when that module is deleted androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity) diff --git a/mesh_service_example/README.md b/mesh_service_example/README.md deleted file mode 100644 index 3804db328..000000000 --- a/mesh_service_example/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# mesh_service_example - -> **DEPRECATED — scheduled for removal in a future release.** -> -> This module is no longer maintained and will be deleted once the new public API documentation is -> available. Do not add new code here. Do not use it as a template for new integrations. -> -> For integrating with the Meshtastic service from your own app, refer to the `:core:api` module -> README at [`core/api/README.md`](../core/api/README.md). - -## What this was - -`mesh_service_example` was a sample Android application demonstrating how to bind to the -`IMeshService` AIDL interface and exchange data with the Meshtastic radio service. It is kept in -the repository only to avoid breaking the CI assemble task (`mesh_service_example:assembleDebug`) -and the JitPack publication that consumers may reference, until those are formally retired. - -## License - -See the root `LICENSE` file. diff --git a/mesh_service_example/build.gradle.kts b/mesh_service_example/build.gradle.kts deleted file mode 100644 index 793735dda..000000000 --- a/mesh_service_example/build.gradle.kts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import com.android.build.api.dsl.ApplicationExtension -import org.meshtastic.buildlogic.FlavorDimension -import org.meshtastic.buildlogic.MeshtasticFlavor - -plugins { - alias(libs.plugins.meshtastic.android.application) - alias(libs.plugins.meshtastic.android.application.compose) - alias(libs.plugins.kotlin.parcelize) - alias(libs.plugins.kotlin.serialization) -} - -configure { - namespace = "com.meshtastic.android.meshserviceexample" - defaultConfig { - // Force this app to use the Google variant of any modules it's using that apply AndroidLibraryConventionPlugin - missingDimensionStrategy(FlavorDimension.marketplace.name, MeshtasticFlavor.google.name) - } - - testOptions { unitTests.isReturnDefaultValues = true } -} - -dependencies { - implementation(projects.core.api) - implementation(projects.core.model) - implementation(projects.core.proto) - - implementation(libs.androidx.activity.compose) - implementation(libs.jetbrains.lifecycle.viewmodel.compose) - implementation(libs.jetbrains.lifecycle.runtime) - implementation(libs.compose.multiplatform.material3) - implementation(libs.androidx.compose.material.iconsExtended) - implementation(libs.material) - - testImplementation(libs.junit) - testRuntimeOnly(libs.junit.vintage.engine) - testImplementation(libs.kotlinx.coroutines.test) -} diff --git a/mesh_service_example/detekt-baseline.xml b/mesh_service_example/detekt-baseline.xml deleted file mode 100644 index ecf2e0cce..000000000 --- a/mesh_service_example/detekt-baseline.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/mesh_service_example/proguard-rules.pro b/mesh_service_example/proguard-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/mesh_service_example/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/mesh_service_example/src/main/AndroidManifest.xml b/mesh_service_example/src/main/AndroidManifest.xml deleted file mode 100644 index b8ffa4cae..000000000 --- a/mesh_service_example/src/main/AndroidManifest.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt deleted file mode 100644 index d61c6f192..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("DEPRECATION") - -package com.meshtastic.android.meshserviceexample - -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.ServiceConnection -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.compose.foundation.isSystemInDarkTheme -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.platform.LocalContext -import org.meshtastic.core.api.MeshtasticIntent -import org.meshtastic.core.service.IMeshService - -private const val TAG: String = "MeshServiceExample" - -/** - * MainActivity for the MeshServiceExample application. - * - * **DEPRECATED.** This entire module (`mesh_service_example`) is scheduled for removal in a future release. Do not use - * it as a template for new integrations. See `:core:api` README for the current public API surface. - */ -@Deprecated( - message = - "mesh_service_example is deprecated and will be removed in a future release. " + - "See core/api/README.md for integration guidance.", -) -class MainActivity : ComponentActivity() { - - private var meshService: IMeshService? = null - private var isMeshServiceBound = false - - private val viewModel: MeshServiceViewModel by viewModels() - - private val serviceConnection = - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - meshService = IMeshService.Stub.asInterface(service) - Log.i(TAG, "Connected to MeshService") - isMeshServiceBound = true - viewModel.onServiceConnected(meshService) - } - - override fun onServiceDisconnected(name: ComponentName?) { - meshService = null - isMeshServiceBound = false - viewModel.onServiceDisconnected() - } - } - - private val meshtasticReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - Log.d(TAG, "BroadcastReceiver onReceive: ${intent?.action}") - intent?.let { viewModel.handleIncomingIntent(it) } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - - bindMeshService() - - val intentFilter = - IntentFilter().apply { - addAction(MeshtasticIntent.ACTION_NODE_CHANGE) - addAction(MeshtasticIntent.ACTION_CONNECTION_CHANGED) - addAction(MeshtasticIntent.ACTION_MESH_CONNECTED) - addAction(MeshtasticIntent.ACTION_MESH_DISCONNECTED) - addAction(MeshtasticIntent.ACTION_MESSAGE_STATUS) - addAction(MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_POSITION_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_TELEMETRY_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_NODEINFO_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_PLUGIN) - addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_FORWARDER) - addAction(MeshtasticIntent.ACTION_RECEIVED_DETECTION_SENSOR_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_PRIVATE_APP) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(meshtasticReceiver, intentFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") - registerReceiver(meshtasticReceiver, intentFilter) - } - - setContent { ExampleTheme { MainScreen(viewModel) } } - } - - override fun onDestroy() { - super.onDestroy() - unregisterReceiver(meshtasticReceiver) - unbindMeshService() - } - - private fun bindMeshService() { - try { - Log.i(TAG, "Attempting to bind to Mesh Service...") - val intent = Intent("com.geeksville.mesh.Service") - - val resolveInfo = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.queryIntentServices(intent, PackageManager.ResolveInfoFlags.of(0)) - } else { - @Suppress("DEPRECATION") - packageManager.queryIntentServices(intent, 0) - } - - if (resolveInfo.isNotEmpty()) { - val serviceInfo = resolveInfo[0].serviceInfo - intent.setClassName(serviceInfo.packageName, serviceInfo.name) - Log.i(TAG, "Found service in package: ${serviceInfo.packageName}") - } else { - Log.w(TAG, "No service found for action com.geeksville.mesh.Service. Falling back to default.") - intent.setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") - } - - val success = bindService(intent, serviceConnection, BIND_AUTO_CREATE) - if (!success) { - Log.e(TAG, "bindService returned false") - } - } catch (e: SecurityException) { - Log.e(TAG, "SecurityException while binding: ${e.message}") - } - } - - private fun unbindMeshService() { - if (isMeshServiceBound) { - try { - unbindService(serviceConnection) - } catch (e: IllegalArgumentException) { - Log.w(TAG, "MeshService not registered or already unbound: ${e.message}") - } - isMeshServiceBound = false - meshService = null - } - } -} - -@Composable -fun ExampleTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colorScheme = - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> darkColorScheme() - else -> lightColorScheme() - } - - MaterialTheme(colorScheme = colorScheme, content = content) -} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt deleted file mode 100644 index 408a37d25..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt +++ /dev/null @@ -1,585 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("TooManyFunctions") - -package com.meshtastic.android.meshserviceexample - -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.PaddingValues -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.heightIn -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.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Message -import androidx.compose.material.icons.automirrored.rounded.Send -import androidx.compose.material.icons.rounded.AccountCircle -import androidx.compose.material.icons.rounded.BatteryUnknown -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.GpsFixed -import androidx.compose.material.icons.rounded.GpsOff -import androidx.compose.material.icons.rounded.Hub -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.MyLocation -import androidx.compose.material.icons.rounded.PersonSearch -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material.icons.rounded.Route -import androidx.compose.material.icons.rounded.Router -import androidx.compose.material.icons.rounded.SignalCellularAlt -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -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.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -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.rememberCoroutineScope -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.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.proto.PortNum - -@Composable -fun ListItem( - text: String, - supportingText: String? = null, - leadingIcon: ImageVector? = null, - trailingIcon: ImageVector? = null, -) { - androidx.compose.material3.ListItem( - headlineContent = { Text(text) }, - supportingContent = supportingText?.let { { Text(it) } }, - leadingContent = leadingIcon?.let { { Icon(it, contentDescription = null) } }, - trailingContent = trailingIcon?.let { { Icon(it, contentDescription = null) } }, - ) -} - -@Composable -fun TitledCard(title: String, content: @Composable () -> Unit) { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 12.dp), - ) - content() - } - } -} - -@Composable -fun SectionHeader(title: String, expanded: Boolean, onExpandClick: () -> Unit, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth().clickable { onExpandClick() }, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) - Icon( - imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, - contentDescription = if (expanded) "Collapse" else "Expand", - tint = MaterialTheme.colorScheme.primary, - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainScreen(viewModel: MeshServiceViewModel) { - val isConnected by viewModel.serviceConnectionStatus.collectAsState() - val connectionState by viewModel.connectionState.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - - Scaffold( - modifier = Modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopAppBar( - title = { TopBarTitle(isConnected, connectionState) }, - actions = { - IconButton( - onClick = { - viewModel.requestNodes() - scope.launch { snackbarHostState.showSnackbar("Refreshing nodes...") } - }, - ) { - Icon(Icons.Rounded.Refresh, contentDescription = "Refresh Nodes") - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - ), - ) - }, - ) { innerPadding -> - MainContent(viewModel, innerPadding, snackbarHostState) - } -} - -@Composable -private fun TopBarTitle(isConnected: Boolean, connectionState: String) { - Column { - Text( - text = "Mesh Service Example", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleLarge, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - val statusColor = - if (isConnected) { - Color.Green - } else { - MaterialTheme.colorScheme.error - } - Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(statusColor)) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (isConnected) "Connected ($connectionState)" else "Disconnected", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -@Suppress("LongMethod") -private fun MainContent( - viewModel: MeshServiceViewModel, - innerPadding: PaddingValues, - snackbarHostState: SnackbarHostState, -) { - val myNodeInfo by viewModel.myNodeInfo.collectAsState() - val myId by viewModel.myId.collectAsState() - val nodes by viewModel.nodes.collectAsState() - val lastMessage by viewModel.message.collectAsState() - val packetLog by viewModel.packetLog.collectAsState() - - var nodesExpanded by remember { mutableStateOf(false) } - var logExpanded by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - LazyColumn( - modifier = Modifier.padding(innerPadding).fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - item { MyInfoSection(myId, myNodeInfo) } - item { TitledCard(title = "Messaging") { MessagingSection(viewModel, lastMessage) } } - item { TitledCard(title = "Test Special PortNums") { SpecialAppSection(viewModel) } } - - item { - SectionHeader( - title = "Mesh Nodes (${nodes.size})", - expanded = nodesExpanded, - onExpandClick = { nodesExpanded = !nodesExpanded }, - ) - } - - if (nodesExpanded) { - if (nodes.isEmpty()) { - item { EmptyNodeState() } - } else { - items(nodes) { node -> - Card(modifier = Modifier.fillMaxWidth()) { - val nodeLabel = node.user?.longName ?: node.user?.id ?: "Unknown Node" - NodeItem(node) { action -> - scope.launch { - when (action) { - "traceroute" -> { - viewModel.requestTraceroute(node.num) - snackbarHostState.showSnackbar("Traceroute requested for $nodeLabel") - } - "telemetry" -> { - viewModel.requestTelemetry(node.num) - snackbarHostState.showSnackbar("Telemetry requested for $nodeLabel") - } - "neighbors" -> { - viewModel.requestNeighborInfo(node.num) - snackbarHostState.showSnackbar("Neighbor info requested for $nodeLabel") - } - "position" -> { - viewModel.requestPosition(node.num) - snackbarHostState.showSnackbar("Position requested for $nodeLabel") - } - "userinfo" -> { - viewModel.requestUserInfo(node.num) - snackbarHostState.showSnackbar("User info requested for $nodeLabel") - } - "connstatus" -> { - viewModel.requestDeviceConnectionStatus(node.num) - snackbarHostState.showSnackbar("Connection status requested for $nodeLabel") - } - } - } - } - } - } - } - } - - item { - SectionHeader(title = "Packet Log", expanded = logExpanded, onExpandClick = { logExpanded = !logExpanded }) - } - - if (logExpanded) { - item { - Card(modifier = Modifier.fillMaxWidth()) { - Box(modifier = Modifier.padding(16.dp)) { PacketLogContent(packetLog) } - } - } - } - - item { ActionButtons(viewModel, snackbarHostState) } - item { Spacer(modifier = Modifier.height(16.dp)) } - } -} - -@Composable -fun SpecialAppSection(viewModel: MeshServiceViewModel) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { viewModel.sendSpecialPacket(PortNum.ATAK_PLUGIN) }, modifier = Modifier.weight(1f)) { - Text("Send ATAK") - } - Button( - onClick = { viewModel.sendSpecialPacket(PortNum.DETECTION_SENSOR_APP) }, - modifier = Modifier.weight(1f), - ) { - Text("Send Sensor") - } - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { viewModel.sendSpecialPacket(PortNum.PRIVATE_APP) }, modifier = Modifier.weight(1f)) { - Text("Send Private") - } - } - } -} - -@Composable -private fun PacketLogContent(log: List) { - Column(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp).verticalScroll(rememberScrollState())) { - if (log.isEmpty()) { - Text( - text = "No packets yet.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) - } else { - log.forEach { entry -> - Text( - text = entry, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(vertical = 2.dp), - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - } - } - } -} - -@Composable -private fun MyInfoSection(myId: String?, myNodeInfo: org.meshtastic.core.model.MyNodeInfo?) { - TitledCard(title = "My Node Information") { - ListItem( - text = "Long ID", - supportingText = myId ?: "N/A", - leadingIcon = Icons.Rounded.AccountCircle, - trailingIcon = null, - ) - ListItem( - text = "Firmware", - supportingText = myNodeInfo?.firmwareString ?: "N/A", - leadingIcon = Icons.Rounded.Info, - trailingIcon = null, - ) - } -} - -@Composable -private fun EmptyNodeState() { - Text( - text = "No mesh nodes discovered yet.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), - textAlign = TextAlign.Center, - ) -} - -@Composable -fun MessagingSection(viewModel: MeshServiceViewModel, lastMessage: String) { - var textToSend by remember { mutableStateOf("") } - - Column(modifier = Modifier.padding(16.dp)) { - if (lastMessage.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - ), - ) { - ListItem( - text = "Last Received", - supportingText = lastMessage, - leadingIcon = Icons.AutoMirrored.Rounded.Message, - trailingIcon = null, - ) - } - Spacer(modifier = Modifier.height(12.dp)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = textToSend, - onValueChange = { textToSend = it }, - modifier = Modifier.weight(1f), - label = { Text("Send broadcast message") }, - shape = MaterialTheme.shapes.large, - ) - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { - if (textToSend.isNotBlank()) { - viewModel.sendMessage(textToSend) - textToSend = "" - } - }, - modifier = Modifier.size(56.dp), - shape = MaterialTheme.shapes.large, - contentPadding = PaddingValues(0.dp), - ) { - Icon(imageVector = Icons.AutoMirrored.Rounded.Send, contentDescription = "Send") - } - } - } -} - -@Composable -fun NodeItem(node: NodeInfo, onAction: (String) -> Unit) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - NodeItemHeader(node) - Spacer(modifier = Modifier.height(8.dp)) - NodeItemActions(node.isOnline, onAction) - } -} - -@Composable -private fun NodeItemHeader(node: NodeInfo) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(contentAlignment = Alignment.BottomEnd) { - Icon( - imageVector = Icons.Rounded.AccountCircle, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.outline, - ) - if (node.isOnline) { - Box( - modifier = - Modifier.size(14.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - .padding(2.dp) - .clip(CircleShape) - .background(Color.Green), - ) - } - } - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = node.user?.longName ?: "Unknown Node", - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = "ID: ${node.user?.id ?: "N/A"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -private fun NodeItemActions(isOnline: Boolean, onAction: (String) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = { onAction("traceroute") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.Route, "Traceroute", Modifier.size(20.dp), MaterialTheme.colorScheme.primary) - } - IconButton(onClick = { onAction("telemetry") }, modifier = Modifier.size(40.dp)) { - Icon( - @Suppress("DEPRECATION") // AutoMirrored variant not available in current icons version - Icons.Rounded.BatteryUnknown, - "Telemetry", - Modifier.size(20.dp), - MaterialTheme.colorScheme.secondary, - ) - } - IconButton(onClick = { onAction("position") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.MyLocation, "Position", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary) - } - IconButton(onClick = { onAction("neighbors") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.Hub, "Neighbors", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary) - } - IconButton(onClick = { onAction("userinfo") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.PersonSearch, "User Info", Modifier.size(20.dp), MaterialTheme.colorScheme.outline) - } - IconButton(onClick = { onAction("connstatus") }, modifier = Modifier.size(40.dp)) { - Icon( - Icons.Rounded.SignalCellularAlt, - "Conn Status", - Modifier.size(20.dp), - MaterialTheme.colorScheme.outline, - ) - } - if (isOnline) { - Icon( - imageVector = Icons.Rounded.Router, - contentDescription = "Online", - tint = androidx.compose.ui.graphics.Color.Green.copy(alpha = 0.5f), - modifier = Modifier.padding(start = 8.dp).size(20.dp), - ) - } - } -} - -@Composable -private fun ActionButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) { - val scope = rememberCoroutineScope() - TitledCard(title = "Device Controls") { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - GpsButtons(viewModel, snackbarHostState) - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - viewModel.rebootLocalDevice() - scope.launch { snackbarHostState.showSnackbar("Reboot Requested") } - }, - shape = MaterialTheme.shapes.medium, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Icon(imageVector = Icons.Rounded.RestartAlt, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Reboot Radio") - } - } - } -} - -@Composable -private fun GpsButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) { - val scope = rememberCoroutineScope() - val colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - modifier = Modifier.weight(1f), - onClick = { - viewModel.startProvideLocation() - scope.launch { snackbarHostState.showSnackbar("GPS Sharing Started") } - }, - shape = MaterialTheme.shapes.medium, - colors = colors, - ) { - Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Start GPS", style = MaterialTheme.typography.labelLarge) - } - Button( - modifier = Modifier.weight(1f), - onClick = { - viewModel.stopProvideLocation() - scope.launch { snackbarHostState.showSnackbar("GPS Sharing Stopped") } - }, - shape = MaterialTheme.shapes.medium, - colors = colors, - ) { - Icon(imageVector = Icons.Rounded.GpsOff, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Stop GPS", style = MaterialTheme.typography.labelLarge) - } - } -} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt deleted file mode 100644 index 7c72516bf..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt +++ /dev/null @@ -1,363 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding - -package com.meshtastic.android.meshserviceexample - -import android.content.Intent -import android.os.Build -import android.os.Parcelable -import android.os.RemoteException -import android.util.Log -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.toDate -import org.meshtastic.core.common.util.toInstant -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.service.IMeshService -import org.meshtastic.proto.PortNum -import java.text.SimpleDateFormat -import java.util.Locale -import kotlin.random.Random - -private const val TAG = "MeshServiceViewModel" - -/** ViewModel for MeshServiceExample. Handles interaction with IMeshService AIDL and manages UI state. */ -@Suppress("TooManyFunctions") -class MeshServiceViewModel : ViewModel() { - - private var meshService: IMeshService? = null - - private val _myNodeInfo = MutableStateFlow(null) - val myNodeInfo: StateFlow = _myNodeInfo.asStateFlow() - - private val _myId = MutableStateFlow(null) - val myId: StateFlow = _myId.asStateFlow() - - private val _nodes = MutableStateFlow>(emptyList()) - val nodes: StateFlow> = _nodes.asStateFlow() - - private val _serviceConnectionStatus = MutableStateFlow(false) - val serviceConnectionStatus: StateFlow = _serviceConnectionStatus.asStateFlow() - - private val _message = MutableStateFlow("") - val message: StateFlow = _message.asStateFlow() - - private val _connectionState = MutableStateFlow("UNKNOWN") - val connectionState: StateFlow = _connectionState.asStateFlow() - - private val _packetLog = MutableStateFlow>(emptyList()) - val packetLog: StateFlow> = _packetLog.asStateFlow() - - fun onServiceConnected(service: IMeshService?) { - meshService = service - _serviceConnectionStatus.value = true - updateAllData() - addToLog("Service Connected") - } - - fun onServiceDisconnected() { - meshService = null - _serviceConnectionStatus.value = false - addToLog("Service Disconnected") - } - - private fun updateAllData() { - requestMyNodeInfo() - requestNodes() - updateConnectionState() - updateMyId() - } - - fun updateMyId() { - meshService?.let { - try { - _myId.value = it.myId - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get MyId", e) - } - } - } - - fun updateConnectionState() { - meshService?.let { - try { - val state = it.connectionState() ?: "UNKNOWN" - _connectionState.value = state - addToLog("Connection State: $state") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get connection state", e) - } - } - } - - fun sendMessage(text: String) { - meshService?.let { service -> - try { - val packet = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = text.encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - from = DataPacket.ID_LOCAL, - time = nowMillis, - id = service.packetId, - status = MessageStatus.UNKNOWN, - hopLimit = 3, - channel = 0, - wantAck = true, - ) - service.send(packet) - Log.d(TAG, "Message sent successfully, assigned ID: ${packet.id}") - addToLog("Sent: $text (ID: ${packet.id})") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to send message", e) - addToLog("Failed to send message: ${e.message}") - } - } ?: Log.w(TAG, "MeshService is not bound, cannot send message") - } - - fun sendSpecialPacket(portNum: PortNum) { - meshService?.let { service -> - try { - val packet = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = "Special Payload for ${portNum.name}".encodeToByteArray().toByteString(), - dataType = portNum.value, - from = DataPacket.ID_LOCAL, - time = nowMillis, - id = service.packetId, - status = MessageStatus.UNKNOWN, - hopLimit = 3, - channel = 0, - wantAck = true, - ) - service.send(packet) - addToLog("Sent ${portNum.name} Packet (ID: ${packet.id})") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to send special packet", e) - addToLog("Failed to send ${portNum.name} packet: ${e.message}") - } - } - } - - fun requestMyNodeInfo() { - meshService?.let { - try { - _myNodeInfo.value = it.myNodeInfo - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get MyNodeInfo", e) - } - } - } - - fun requestNodes() { - meshService?.let { - try { - _nodes.value = it.nodes ?: emptyList() - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get nodes", e) - } - } - } - - fun startProvideLocation() { - try { - meshService?.startProvideLocation() - addToLog("Started GPS sharing") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to start providing location", e) - } - } - - fun stopProvideLocation() { - try { - meshService?.stopProvideLocation() - addToLog("Stopped GPS sharing") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to stop providing location", e) - } - } - - fun requestTraceroute(nodeNum: Int) { - meshService?.let { - try { - it.requestTraceroute(Random.nextInt(), nodeNum) - Log.i(TAG, "Traceroute requested for node $nodeNum") - addToLog("Requested Traceroute for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request traceroute", e) - } - } - } - - fun requestTelemetry(nodeNum: Int) { - meshService?.let { - try { - it.requestTelemetry(Random.nextInt(), nodeNum, 1) - Log.i(TAG, "Telemetry requested for node $nodeNum") - addToLog("Requested Telemetry for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request telemetry", e) - } - } - } - - fun requestNeighborInfo(nodeNum: Int) { - meshService?.let { - try { - it.requestNeighborInfo(Random.nextInt(), nodeNum) - Log.i(TAG, "Neighbor info requested for node $nodeNum") - addToLog("Requested Neighbors for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request neighbor info", e) - } - } - } - - fun requestPosition(nodeNum: Int) { - meshService?.let { - try { - it.requestPosition(nodeNum, Position(0.0, 0.0, 0)) - Log.i(TAG, "Position requested for node $nodeNum") - addToLog("Requested Position for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request position", e) - } - } - } - - fun requestUserInfo(nodeNum: Int) { - meshService?.let { - try { - it.requestUserInfo(nodeNum) - Log.i(TAG, "User info requested for node $nodeNum") - addToLog("Requested User Info for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request user info", e) - } - } - } - - fun requestDeviceConnectionStatus(nodeNum: Int) { - meshService?.let { - try { - it.getDeviceConnectionStatus(Random.nextInt(), nodeNum) - Log.i(TAG, "Device connection status requested for node $nodeNum") - addToLog("Requested Connection Status for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request device connection status", e) - } - } - } - - fun rebootLocalDevice() { - meshService?.let { - try { - it.requestReboot(Random.nextInt(), 0) - Log.w(TAG, "Local reboot requested!") - addToLog("Requested Local Reboot") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request reboot", e) - } - } - } - - fun handleIncomingIntent(intent: Intent) { - val action = intent.action ?: return - Log.d(TAG, "Received broadcast: $action") - - when (action) { - "com.geeksville.mesh.NODE_CHANGE" -> handleNodeChange(intent) - "com.geeksville.mesh.CONNECTION_CHANGED", - "com.geeksville.mesh.MESH_CONNECTED", - "com.geeksville.mesh.MESH_DISCONNECTED", - -> updateConnectionState() - - "com.geeksville.mesh.MESSAGE_STATUS" -> handleMessageStatus(intent) - else -> - if (action.startsWith("com.geeksville.mesh.RECEIVED.")) { - handleReceivedPacket(action, intent) - } - } - } - - private fun handleNodeChange(intent: Intent) { - val nodeInfo = intent.getParcelableCompat("com.geeksville.mesh.NodeInfo", NodeInfo::class.java) - nodeInfo?.let { ni -> - Log.d(TAG, "Node updated: ${ni.num}") - _nodes.value = - _nodes.value.toMutableList().apply { - val index = indexOfFirst { it.num == ni.num } - if (index != -1) set(index, ni) else add(ni) - } - } - } - - private fun handleMessageStatus(intent: Intent) { - val id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0) - val status = intent.getParcelableCompat("com.geeksville.mesh.Status", MessageStatus::class.java) - Log.d(TAG, "Message Status for ID $id: $status") - addToLog("Msg Status ID $id: $status") - } - - private fun handleReceivedPacket(action: String, intent: Intent) { - val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java) - if (packet == null) { - Log.e(TAG, "Received packet extra was NULL for action: $action") - addToLog("Error: Packet payload was null for $action") - return - } - - Log.d(TAG, "Packet received: $packet") - - if (packet.dataType == PortNum.TEXT_MESSAGE_APP.value) { - val receivedText = packet.bytes?.utf8() ?: "" - _message.value = "From ${packet.from}: $receivedText" - addToLog("Received Text from ${packet.from}: $receivedText") - } else { - val type = action.substringAfterLast(".") - addToLog("Received $type from ${packet.from}. Check Logcat for details.") - } - } - - private fun addToLog(entry: String) { - val date = nowMillis.toInstant().toDate() - val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(date) - val logEntry = "[$timestamp] $entry" - Log.d(TAG, "Log: $logEntry") - @Suppress("MagicNumber") - _packetLog.value = (listOf(logEntry) + _packetLog.value).take(50) - } - - private fun Intent.getParcelableCompat(key: String, clazz: Class): T? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelableExtra(key, clazz) - } else { - @Suppress("DEPRECATION") - getParcelableExtra(key) - } -} diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml deleted file mode 100644 index 07d5da9cb..000000000 --- a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d114..000000000 --- a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index 6f3b755bf..000000000 --- a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml b/mesh_service_example/src/main/res/values-ar-rSA/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml b/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-be-rBY/strings.xml b/mesh_service_example/src/main/res/values-be-rBY/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-be-rBY/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml b/mesh_service_example/src/main/res/values-bg-rBG/strings.xml deleted file mode 100644 index bebf8fbdd..000000000 --- a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Изпратете съобщение за здравей - diff --git a/mesh_service_example/src/main/res/values-ca-rES/strings.xml b/mesh_service_example/src/main/res/values-ca-rES/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ca-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml b/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-de-rDE/strings.xml b/mesh_service_example/src/main/res/values-de-rDE/strings.xml deleted file mode 100644 index 968230ec2..000000000 --- a/mesh_service_example/src/main/res/values-de-rDE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Beispiel MeshService - Hallo Nachricht senden - diff --git a/mesh_service_example/src/main/res/values-el-rGR/strings.xml b/mesh_service_example/src/main/res/values-el-rGR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-el-rGR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-es-rES/strings.xml b/mesh_service_example/src/main/res/values-es-rES/strings.xml deleted file mode 100644 index 8abd298f5..000000000 --- a/mesh_service_example/src/main/res/values-es-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Ejemplo de servicio de red - Enviar Mensaje Hola - diff --git a/mesh_service_example/src/main/res/values-et-rEE/strings.xml b/mesh_service_example/src/main/res/values-et-rEE/strings.xml deleted file mode 100644 index dd6ff8304..000000000 --- a/mesh_service_example/src/main/res/values-et-rEE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceNäidis - Saada Tere sõnum - diff --git a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml b/mesh_service_example/src/main/res/values-fi-rFI/strings.xml deleted file mode 100644 index 2da506dda..000000000 --- a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExamplebled - Lähetä tervehdysviesti - diff --git a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml b/mesh_service_example/src/main/res/values-fr-rFR/strings.xml deleted file mode 100644 index 2b9ff6e40..000000000 --- a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exemple de service de maillage - Envoyer un message d’annonce - diff --git a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml b/mesh_service_example/src/main/res/values-ga-rIE/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-gl-rES/strings.xml b/mesh_service_example/src/main/res/values-gl-rES/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-gl-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml b/mesh_service_example/src/main/res/values-hr-rHR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml b/mesh_service_example/src/main/res/values-ht-rHT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml b/mesh_service_example/src/main/res/values-hu-rHU/strings.xml deleted file mode 100644 index 1cff8d920..000000000 --- a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Hello Üzenet Küldés - diff --git a/mesh_service_example/src/main/res/values-is-rIS/strings.xml b/mesh_service_example/src/main/res/values-is-rIS/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-is-rIS/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-it-rIT/strings.xml b/mesh_service_example/src/main/res/values-it-rIT/strings.xml deleted file mode 100644 index dd7addd1d..000000000 --- a/mesh_service_example/src/main/res/values-it-rIT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Invia Messaggio di Saluto - diff --git a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml b/mesh_service_example/src/main/res/values-iw-rIL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml b/mesh_service_example/src/main/res/values-ja-rJP/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml b/mesh_service_example/src/main/res/values-ko-rKR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml b/mesh_service_example/src/main/res/values-lt-rLT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml b/mesh_service_example/src/main/res/values-nl-rNL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-no-rNO/strings.xml b/mesh_service_example/src/main/res/values-no-rNO/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-no-rNO/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml b/mesh_service_example/src/main/res/values-pl-rPL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml b/mesh_service_example/src/main/res/values-pt-rBR/strings.xml deleted file mode 100644 index 4e232be75..000000000 --- a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - ExemploServiçoMesh - Enviar Mensagem de Olá - diff --git a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml b/mesh_service_example/src/main/res/values-pt-rPT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml b/mesh_service_example/src/main/res/values-ro-rRO/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml b/mesh_service_example/src/main/res/values-ru-rRU/strings.xml deleted file mode 100644 index ba088c7e3..000000000 --- a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Отправить приветственное сообщение - diff --git a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml b/mesh_service_example/src/main/res/values-sk-rSK/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml b/mesh_service_example/src/main/res/values-sl-rSI/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml b/mesh_service_example/src/main/res/values-sq-rAL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-srp/strings.xml b/mesh_service_example/src/main/res/values-srp/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-srp/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml b/mesh_service_example/src/main/res/values-sv-rSE/strings.xml deleted file mode 100644 index f9271ce44..000000000 --- a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Mesh-service exempel - Skicka Hej-meddelande - diff --git a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml b/mesh_service_example/src/main/res/values-tr-rTR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml b/mesh_service_example/src/main/res/values-uk-rUA/strings.xml deleted file mode 100644 index 37d7a2bb2..000000000 --- a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Надіслати привітальне повідомлення - diff --git a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml b/mesh_service_example/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml b/mesh_service_example/src/main/res/values-zh-rTW/strings.xml deleted file mode 100644 index 16c04c5d3..000000000 --- a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Mesh 服務範例 - 發送打招呼訊息 - diff --git a/mesh_service_example/src/main/res/values/colors.xml b/mesh_service_example/src/main/res/values/colors.xml deleted file mode 100644 index a6b3daec9..000000000 --- a/mesh_service_example/src/main/res/values/colors.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/values/strings.xml b/mesh_service_example/src/main/res/values/strings.xml deleted file mode 100644 index e194d4b9b..000000000 --- a/mesh_service_example/src/main/res/values/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - MeshServiceExample - diff --git a/mesh_service_example/src/main/res/values/themes.xml b/mesh_service_example/src/main/res/values/themes.xml deleted file mode 100644 index e8f8fe799..000000000 --- a/mesh_service_example/src/main/res/values/themes.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - -