refactor: migrate from Hilt to Koin and expand KMP common modules (#4746)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-09 20:19:46 -05:00
committed by GitHub
parent a5390a80e7
commit 875cf1cff2
440 changed files with 3738 additions and 3508 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.compass
import android.content.Context
@@ -22,29 +21,19 @@ import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
import org.koin.core.annotation.Single
private const val ROTATION_MATRIX_SIZE = 9
private const val ORIENTATION_SIZE = 3
private const val FULL_CIRCLE_DEGREES = 360f
data class HeadingState(
val heading: Float? = null, // 0..360 degrees
val hasSensor: Boolean = true,
val accuracy: Int = SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM,
)
@Single
class AndroidCompassHeadingProvider(private val context: Context) : CompassHeadingProvider {
class CompassHeadingProvider @Inject constructor(@ApplicationContext private val context: Context) {
/**
* Emits compass heading in degrees (magnetic). Callers can correct for true north using the latest location data
* when available.
*/
fun headingUpdates(): Flow<HeadingState> = callbackFlow {
override fun headingUpdates(): Flow<HeadingState> = callbackFlow {
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
if (sensorManager == null) {
trySend(HeadingState(hasSensor = false))
@@ -93,7 +82,7 @@ class CompassHeadingProvider @Inject constructor(@ApplicationContext private val
}
SensorManager.getOrientation(rotationMatrix, orientation)
var azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
val azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
val heading = (azimuth + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
trySend(HeadingState(heading = heading, hasSensor = true, accuracy = event.accuracy))

View File

@@ -0,0 +1,28 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.compass
import android.hardware.GeomagneticField
import org.koin.core.annotation.Single
@Single
class AndroidMagneticFieldProvider : MagneticFieldProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float {
val geomagneticField = GeomagneticField(latitude.toFloat(), longitude.toFloat(), altitude.toFloat(), timeMillis)
return geomagneticField.declination
}
}

View File

@@ -25,31 +25,18 @@ import androidx.core.content.ContextCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import dagger.hilt.android.qualifiers.ApplicationContext
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 javax.inject.Inject
data class PhoneLocationState(
val permissionGranted: Boolean,
val providerEnabled: Boolean,
val location: Location? = null,
) {
val hasFix: Boolean
get() = location != null
}
@Single
class AndroidPhoneLocationProvider(private val context: Context, private val dispatchers: CoroutineDispatchers) :
PhoneLocationProvider {
class PhoneLocationProvider
@Inject
constructor(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
) {
// Streams phone location (and permission/provider state) so the compass stays gated on real fixes.
fun locationUpdates(): Flow<PhoneLocationState> = callbackFlow {
override fun locationUpdates(): Flow<PhoneLocationState> = callbackFlow {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
if (locationManager == null) {
trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
@@ -59,7 +46,7 @@ constructor(
if (!hasLocationPermission()) {
trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
close() // Just closing it off, like how I'll close my legs around your waist
close()
return@callbackFlow
}
@@ -70,7 +57,7 @@ constructor(
PhoneLocationState(
permissionGranted = true,
providerEnabled = LocationManagerCompat.isLocationEnabled(locationManager),
location = lastLocation,
location = lastLocation?.toPhoneLocation(),
),
)
}
@@ -96,7 +83,6 @@ constructor(
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
try {
// Get initial fix if available
lastLocation =
providers
.mapNotNull { provider -> locationManager.getLastKnownLocation(provider) }
@@ -131,6 +117,9 @@ constructor(
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
private fun Location.toPhoneLocation() =
PhoneLocation(latitude = latitude, longitude = longitude, altitude = altitude, timeMillis = time)
companion object {
private const val MIN_UPDATE_INTERVAL_MS = 1_000L
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.material.icons.Icons

View File

@@ -20,7 +20,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource

View File

@@ -52,6 +52,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.position
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@@ -59,6 +60,7 @@ import org.meshtastic.proto.Config
private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
private const val COMPASS_BUTTON_WEIGHT = 0.9f
private const val MAP_HEIGHT_DP = 200
/**
* Displays node position details, last update time, distance, and related actions like requesting position and
@@ -126,8 +128,8 @@ fun PositionSection(
@Composable
private fun PositionMap(node: Node, distance: String?) {
Box(modifier = Modifier.padding(vertical = 4.dp)) {
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(200.dp)) {
InlineMap(node = node, Modifier.fillMaxSize())
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(MAP_HEIGHT_DP.dp)) {
LocalInlineMapProvider.current(node, Modifier.fillMaxSize())
}
if (distance != null && distance.isNotEmpty()) {
Surface(

View File

@@ -17,15 +17,13 @@
package org.meshtastic.feature.node.detail
import kotlinx.coroutines.CoroutineScope
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.feature.node.component.NodeMenuAction
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@Single
class NodeDetailActions
@Inject
constructor(
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,

View File

@@ -21,10 +21,7 @@ import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -55,9 +52,9 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.Node
import org.meshtastic.core.navigation.Route
@@ -94,10 +91,11 @@ private sealed interface NodeDetailOverlay {
fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
viewModel: NodeDetailViewModel = hiltViewModel(),
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
compassViewModel: CompassViewModel? = null,
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
@@ -120,6 +118,7 @@ fun NodeDetailScreen(
navigateToMessages = navigateToMessages,
onNavigate = onNavigate,
onNavigateUp = onNavigateUp,
compassViewModel = compassViewModel,
)
}
@@ -133,12 +132,13 @@ private fun NodeDetailScaffold(
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel? = null,
) {
var activeOverlay by remember { mutableStateOf<NodeDetailOverlay?>(null) }
val inspectionMode = LocalInspectionMode.current
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
val actualCompassViewModel = compassViewModel ?: if (inspectionMode) null else koinViewModel()
val compassUiState by
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
val node = uiState.node
val listState = rememberLazyListState()
@@ -167,7 +167,7 @@ private fun NodeDetailScaffold(
when (action) {
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
is NodeDetailAction.OpenCompass -> {
compassViewModel?.start(action.node, action.displayUnits)
actualCompassViewModel?.start(action.node, action.displayUnits)
activeOverlay = NodeDetailOverlay.Compass
}
else ->
@@ -186,7 +186,7 @@ private fun NodeDetailScaffold(
)
}
NodeDetailOverlays(activeOverlay, node, compassUiState, compassViewModel, { activeOverlay = null }) {
NodeDetailOverlays(activeOverlay, node, compassUiState, actualCompassViewModel, { activeOverlay = null }) {
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
}
}
@@ -200,12 +200,7 @@ private fun NodeDetailContent(
onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = uiState.node != null,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
label = "NodeDetailContent",
modifier = modifier,
) { isNodePresent ->
Crossfade(targetState = uiState.node != null, label = "NodeDetailContent", modifier = modifier) { isNodePresent ->
if (isNodePresent && uiState.node != null) {
NodeDetailList(
node = uiState.node,

View File

@@ -61,7 +61,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@@ -96,8 +95,8 @@ import org.meshtastic.proto.SharedContact
@Composable
fun NodeListScreen(
navigateToNodeDetails: (Int) -> Unit,
viewModel: NodeListViewModel,
onNavigateToChannels: () -> Unit = {},
viewModel: NodeListViewModel = hiltViewModel(),
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
) {
@@ -156,7 +155,9 @@ fun NodeListScreen(
alignment = Alignment.BottomEnd,
),
onImport = { uri ->
viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
viewModel.handleScannedUri(uri.toString()) {
scope.launch { context.showToast(Res.string.channel_invalid) }
}
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,

View File

@@ -51,7 +51,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
@@ -123,7 +122,7 @@ private val LEGEND_DATA =
@Suppress("LongMethod")
@Composable
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()

View File

@@ -46,7 +46,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
@@ -73,7 +72,7 @@ import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
@Composable
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val graphData by viewModel.environmentGraphingData.collectAsStateWithLifecycle()
val filteredTelemetries by viewModel.filteredEnvironmentMetrics.collectAsStateWithLifecycle()

View File

@@ -53,7 +53,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
@@ -78,7 +77,7 @@ import java.text.DecimalFormat
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }

View File

@@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
@@ -61,11 +60,7 @@ import org.meshtastic.feature.node.detail.NodeRequestEffect
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NeighborInfoLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }

View File

@@ -43,7 +43,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
@@ -174,7 +173,7 @@ private fun PaxMetricsChart(
@Composable
@Suppress("MagicNumber", "LongMethod")
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val paxMetrics by metricsViewModel.filteredPaxMetrics.collectAsStateWithLifecycle()
val timeFrame by metricsViewModel.timeFrame.collectAsStateWithLifecycle()

View File

@@ -59,7 +59,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
@@ -172,7 +171,7 @@ private fun ActionButtons(
@Suppress("LongMethod")
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }

View File

@@ -51,7 +51,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
@@ -107,7 +106,7 @@ private val LEGEND_DATA =
@Suppress("LongMethod")
@Composable
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()

View File

@@ -47,7 +47,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
@@ -85,7 +84,7 @@ private val LEGEND_DATA =
@Suppress("LongMethod")
@Composable
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()

View File

@@ -42,7 +42,6 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
@@ -83,7 +82,7 @@ import org.meshtastic.proto.RouteDiscovery
@Composable
fun TracerouteLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
viewModel: MetricsViewModel,
onNavigateUp: () -> Unit,
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
) {

View File

@@ -38,7 +38,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
@@ -53,12 +52,13 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.Position
@Composable
fun TracerouteMapScreen(
metricsViewModel: MetricsViewModel = hiltViewModel(),
metricsViewModel: MetricsViewModel,
requestId: Int,
logUuid: String? = null,
onNavigateUp: () -> Unit,
@@ -102,6 +102,7 @@ private fun TracerouteMapScaffold(
) {
var tracerouteNodesShown by remember { mutableStateOf(0) }
var tracerouteNodesTotal by remember { mutableStateOf(0) }
val insets = LocalTracerouteMapOverlayInsetsProvider.current
Scaffold(
topBar = {
MainAppBar(
@@ -128,10 +129,8 @@ private fun TracerouteMapScaffold(
},
)
Column(
modifier =
Modifier.align(TracerouteMapOverlayInsets.overlayAlignment)
.padding(TracerouteMapOverlayInsets.overlayPadding),
horizontalAlignment = TracerouteMapOverlayInsets.contentHorizontalAlignment,
modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding),
horizontalAlignment = insets.contentHorizontalAlignment,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal)

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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
@@ -14,15 +14,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.compass
package org.meshtastic.feature.node.metrics
import kotlinx.coroutines.flow.Flow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
data class HeadingState(
val heading: Float? = null, // 0..360 degrees
val hasSensor: Boolean = true,
val accuracy: Int = 0,
)
internal object TracerouteMapOverlayInsets {
val overlayAlignment: Alignment = Alignment.BottomEnd
val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp)
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End
interface CompassHeadingProvider {
fun headingUpdates(): Flow<HeadingState>
}

View File

@@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.node.compass
import android.hardware.GeomagneticField
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,7 +37,6 @@ import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.ui.component.precisionBitsToMeters
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.min
@@ -54,13 +51,11 @@ private const val SECONDS_PER_MINUTE = 60
private const val HUNDRED = 100f
private const val MILLIMETERS_PER_METER = 1000f
@HiltViewModel
@Suppress("TooManyFunctions")
class CompassViewModel
@Inject
constructor(
open class CompassViewModel(
private val headingProvider: CompassHeadingProvider,
private val phoneLocationProvider: PhoneLocationProvider,
private val magneticFieldProvider: MagneticFieldProvider,
private val dispatchers: CoroutineDispatchers,
) : ViewModel() {
@@ -192,9 +187,8 @@ constructor(
private fun applyTrueNorthCorrection(heading: Float?, locationState: PhoneLocationState): Float? {
val loc = locationState.location ?: return heading
val baseHeading = heading ?: return null
val geomagnetic =
GeomagneticField(loc.latitude.toFloat(), loc.longitude.toFloat(), loc.altitude.toFloat(), nowMillis)
return (baseHeading + geomagnetic.declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
val declination = magneticFieldProvider.getDeclination(loc.latitude, loc.longitude, loc.altitude, nowMillis)
return (baseHeading + declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
}
private fun formatElapsed(timestampSec: Long): String {
@@ -246,6 +240,8 @@ constructor(
if (distance <= 0) return FULL_CIRCLE_DEGREES / 2
val radians = atan2(accuracy.toDouble(), distance.toDouble())
return Math.toDegrees(radians).toFloat().coerceIn(0f, FULL_CIRCLE_DEGREES / 2)
return radiansToDegrees(radians).toFloat().coerceIn(0f, FULL_CIRCLE_DEGREES / 2)
}
private fun radiansToDegrees(radians: Double): Double = radians * 180.0 / kotlin.math.PI
}

View File

@@ -14,13 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
package org.meshtastic.feature.node.compass
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.Node
@Composable
internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
// No-op for F-Droid builds
interface MagneticFieldProvider {
fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float
}

View File

@@ -0,0 +1,34 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.compass
import kotlinx.coroutines.flow.Flow
data class PhoneLocation(val latitude: Double, val longitude: Double, val altitude: Double, val timeMillis: Long)
data class PhoneLocationState(
val permissionGranted: Boolean,
val providerEnabled: Boolean,
val location: PhoneLocation? = null,
) {
val hasFix: Boolean
get() = location != null
}
interface PhoneLocationProvider {
fun locationUpdates(): Flow<PhoneLocationState>
}

View File

@@ -20,7 +20,6 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -43,20 +42,8 @@ import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import javax.inject.Inject
/**
* UI state for the Node Details screen.
*
* @property node The node being viewed, or null if loading.
* @property nodeName The display name for the node, resolved in the UI.
* @property ourNode Information about the locally connected node.
* @property metricsState Aggregated sensor and signal metrics.
* @property environmentState Standardized environmental sensor data.
* @property availableLogs a set of log types available for this node.
* @property lastTracerouteTime Timestamp of the last successful traceroute request.
* @property lastRequestNeighborsTime Timestamp of the last successful neighbor info request.
*/
/** UI state for the Node Details screen. */
@androidx.compose.runtime.Stable
data class NodeDetailUiState(
val node: Node? = null,
@@ -73,11 +60,8 @@ data class NodeDetailUiState(
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class NodeDetailViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
open class NodeDetailViewModel(
private val savedStateHandle: SavedStateHandle,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
private val serviceRepository: ServiceRepository,

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.service.ServiceAction
@@ -40,12 +41,9 @@ import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_node_text
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@Single
class NodeManagementActions
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
@@ -127,10 +125,8 @@ constructor(
scope.launch(Dispatchers.IO) {
try {
nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) {
Logger.e { "Set node notes IO error: ${ex.message}" }
} catch (ex: java.sql.SQLException) {
Logger.e { "Set node notes SQL error: ${ex.message}" }
} catch (ex: Exception) {
Logger.e(ex) { "Set node notes error" }
}
}
}

View File

@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
@@ -45,15 +46,13 @@ import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
import javax.inject.Inject
import javax.inject.Singleton
sealed class NodeRequestEffect {
data class ShowFeedback(val text: UiText) : NodeRequestEffect()
}
@Singleton
class NodeRequestActions @Inject constructor(private val radioController: RadioController) {
@Single
class NodeRequestActions constructor(private val radioController: RadioController) {
private val _effects = MutableSharedFlow<NodeRequestEffect>()
val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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,15 +14,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.di
package org.meshtastic.feature.node.metrics
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
internal object TracerouteMapOverlayInsets {
val overlayAlignment: Alignment = Alignment.BottomCenter
val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp)
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally
}
@Module
@ComponentScan("org.meshtastic.feature.node")
class FeatureNodeModule

View File

@@ -18,15 +18,16 @@ package org.meshtastic.feature.node.domain.usecase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.feature.node.list.NodeFilterState
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.Config
import javax.inject.Inject
class GetFilteredNodesUseCase @Inject constructor(private val nodeRepository: NodeRepository) {
@Single
class GetFilteredNodesUseCase constructor(private val nodeRepository: NodeRepository) {
@Suppress("CyclomaticComplexMethod", "LongMethod")
operator fun invoke(filter: NodeFilterState, sort: NodeSortOption): Flow<List<Node>> = nodeRepository
.getNodes(

View File

@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
@@ -49,10 +50,9 @@ import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
@Single
class GetNodeDetailsUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,

View File

@@ -17,11 +17,12 @@
package org.meshtastic.feature.node.list
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.NodeSortOption
import javax.inject.Inject
class NodeFilterPreferences @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
@Single
class NodeFilterPreferences constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
val includeUnknown = uiPreferencesDataSource.includeUnknown
val excludeInfrastructure = uiPreferencesDataSource.excludeInfrastructure
val onlyOnline = uiPreferencesDataSource.onlyOnline

View File

@@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.node.list
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -28,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
@@ -41,13 +40,9 @@ import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@Suppress("LongParameterList")
@HiltViewModel
class NodeListViewModel
@Inject
constructor(
open class NodeListViewModel(
private val savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
@@ -138,7 +133,8 @@ constructor(
}
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
fun handleScannedUri(uriString: String, onInvalid: () -> Unit) {
val uri = CommonUri.parse(uriString)
uri.dispatchMeshtasticUri(
onContact = { _sharedContactRequested.value = it },
onChannel = { _requestChannelSet.value = it },

View File

@@ -16,17 +16,12 @@
*/
package org.meshtastic.feature.node.metrics
import android.app.Application
import android.net.Uri
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -40,11 +35,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
@@ -52,7 +44,6 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
@@ -71,26 +62,15 @@ import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import org.meshtastic.proto.Paxcount as ProtoPaxcount
/**
* ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node.
*/
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MetricsViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
private val app: Application,
private val dispatchers: CoroutineDispatchers,
open class MetricsViewModel(
val destNum: Int,
protected val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
@@ -100,8 +80,8 @@ constructor(
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : ViewModel() {
private val nodeIdFromRoute: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
private val nodeIdFromRoute: Int?
get() = destNum
private val manualNodeId = MutableStateFlow<Int?>(null)
private val activeNodeId =
@@ -134,7 +114,8 @@ constructor(
val availableTimeFrames: StateFlow<List<TimeFrame>> =
combine(state, environmentState) { currentState, envState ->
val stateOldest = currentState.oldestTimestampSeconds()
val envOldest = envState.environmentMetrics.minOfOrNull { it.time.toLong() }?.takeIf { it > 0 }
val envOldest =
envState.environmentMetrics.minOfOrNull { it.time.toLong() }?.takeIf { it > 0 } ?: nowSeconds
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: nowSeconds
TimeFrame.entries.filter { it.isAvailable(oldest) }
}
@@ -331,44 +312,10 @@ constructor(
Logger.d { "MetricsViewModel cleared" }
}
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs
writeToUri(uri) { writer ->
writer.appendLine(
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"",
)
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
positions.forEach { position ->
val rxDateTime = dateFormat.format((position.time.toLong() * 1000L).toInstant().toDate())
val latitude = (position.latitude_i ?: 0) * 1e-7
val longitude = (position.longitude_i ?: 0) * 1e-7
val altitude = position.altitude
val satsInView = position.sats_in_view
val speed = position.ground_speed
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
writer.appendLine(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"",
)
}
}
open fun savePositionCSV(uri: Any) {
// To be implemented in platform-specific subclass
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) =
withContext(dispatchers.io) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
}
}
} catch (ex: FileNotFoundException) {
Logger.e(ex) { "Can't write file error" }
}
}
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? {
try {
@@ -379,25 +326,26 @@ constructor(
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) return pax
}
} catch (e: IOException) {
} catch (e: Exception) {
Logger.e(e) { "Failed to parse Paxcount from binary data" }
}
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
val bytes = decodeBase64(base64)
return ProtoPaxcount.ADAPTER.decode(bytes)
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return ProtoPaxcount.ADAPTER.decode(bytes)
}
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
} catch (e: IOException) {
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
} catch (e: NumberFormatException) {
} catch (e: Exception) {
Logger.e(e) { "Failed to parse Paxcount from decoded data" }
}
return null
}
protected open fun decodeBase64(base64: String): ByteArray {
// To be overridden in platform-specific subclass or use KMP library
return ByteArray(0)
}
}

View File

@@ -1,85 +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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15f
@OptIn(MapsComposeExperimentalApi::class)
@Composable
internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val mapColorScheme =
when (dark) {
true -> ComposeMapColorScheme.DARK
else -> ComposeMapColorScheme.LIGHT
}
key(node.num) {
val location = LatLng(node.latitude, node.longitude)
val cameraState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM)
}
GoogleMap(
mapColorScheme = mapColorScheme,
modifier = modifier,
uiSettings =
MapUiSettings(
zoomControlsEnabled = true,
mapToolbarEnabled = false,
compassEnabled = false,
myLocationButtonEnabled = false,
rotationGesturesEnabled = false,
scrollGesturesEnabled = false,
tiltGesturesEnabled = false,
zoomGesturesEnabled = false,
),
cameraPositionState = cameraState,
) {
val precisionMeters = precisionBitsToMeters(node.position.precision_bits ?: 0)
val latLng = LatLng(node.latitude, node.longitude)
if (precisionMeters > 0) {
Circle(
center = latLng,
radius = precisionMeters,
fillColor = Color(node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(node.colors.second),
strokeWidth = 2f,
)
}
MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) }
}
}
}