diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 8dbfded51..f994eabb5 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -2,30 +2,7 @@
- LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()
- LongParameterList:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, dispatchers: CoroutineDispatchers, meshLogRepository: MeshLogRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, tracerouteSnapshotRepository: TracerouteSnapshotRepository, nodeRequestActions: NodeRequestActions, alertManager: AlertManager, getNodeDetailsUseCase: GetNodeDetailsUseCase, )
- LongParameterList:AndroidNodeListViewModel.kt$AndroidNodeListViewModel$( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, radioController: RadioController, nodeManagementActions: NodeManagementActions, getFilteredNodesUseCase: GetFilteredNodesUseCase, nodeFilterPreferences: NodeFilterPreferences, )
- LongParameterList:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, radioConfigRepository: RadioConfigRepository, packetRepository: PacketRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, private val locationRepository: LocationRepository, mapConsentPrefs: MapConsentPrefs, analyticsPrefs: AnalyticsPrefs, homoglyphEncodingPrefs: HomoglyphPrefs, toggleAnalyticsUseCase: ToggleAnalyticsUseCase, toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase, importProfileUseCase: ImportProfileUseCase, exportProfileUseCase: ExportProfileUseCase, exportSecurityConfigUseCase: ExportSecurityConfigUseCase, installProfileUseCase: InstallProfileUseCase, radioConfigUseCase: RadioConfigUseCase, adminActionsUseCase: AdminActionsUseCase, processRadioResponseUseCase: ProcessRadioResponseUseCase, )
- LongParameterList:AndroidSettingsViewModel.kt$AndroidSettingsViewModel$( private val app: Application, radioConfigRepository: RadioConfigRepository, radioController: RadioController, nodeRepository: NodeRepository, uiPrefs: UiPrefs, buildConfigProvider: BuildConfigProvider, databaseManager: DatabaseManager, meshLogPrefs: MeshLogPrefs, setThemeUseCase: SetThemeUseCase, setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, setProvideLocationUseCase: SetProvideLocationUseCase, setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase, setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase, meshLocationUseCase: MeshLocationUseCase, exportDataUseCase: ExportDataUseCase, isOtaCapableUseCase: IsOtaCapableUseCase, )
- MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1000L
- MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-5
- MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-7
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114
- MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200
- MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200
- MagicNumber:StreamInterface.kt$StreamInterface$0xff
- MagicNumber:StreamInterface.kt$StreamInterface$3
- MagicNumber:StreamInterface.kt$StreamInterface$4
- MagicNumber:StreamInterface.kt$StreamInterface$8
- MagicNumber:TCPInterface.kt$TCPInterface$1000
- SwallowedException:NsdManager.kt$ex: IllegalArgumentException
- SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException
- TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception
TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception
- TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable
TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : RadioTransport
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 47439a9e1..485bb8820 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -51,11 +51,11 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
-import org.meshtastic.app.model.UIViewModel
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.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.nfc.NfcScannerEffect
@@ -70,6 +70,7 @@ import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
@@ -206,7 +207,7 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
if (uri.toString().startsWith(DEEP_LINK_BASE_URI)) {
- model.handleNavigationDeepLink(uri)
+ model.handleNavigationDeepLink(uri.toMeshtasticUri())
return
}
diff --git a/app/src/main/kotlin/org/meshtastic/app/model/UIViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/model/UIViewModel.kt
deleted file mode 100644
index 3679b9c61..000000000
--- a/app/src/main/kotlin/org/meshtastic/app/model/UIViewModel.kt
+++ /dev/null
@@ -1,87 +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.model
-
-import android.net.Uri
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.data.repository.FirmwareReleaseRepository
-import org.meshtastic.core.datastore.UiPreferencesDataSource
-import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.model.util.dispatchMeshtasticUri
-import org.meshtastic.core.repository.MeshLogRepository
-import org.meshtastic.core.repository.MeshServiceNotifications
-import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.PacketRepository
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.service.AndroidServiceRepository
-import org.meshtastic.core.service.IMeshService
-import org.meshtastic.core.ui.util.AlertManager
-import org.meshtastic.core.ui.viewmodel.BaseUIViewModel
-
-/**
- * Android-specific thin adapter over [BaseUIViewModel].
- *
- * Adds deep-link / URI handling (requires [android.net.Uri]) and direct [IMeshService] access that cannot live in
- * `commonMain`.
- */
-@KoinViewModel
-@Suppress("LongParameterList", "TooManyFunctions")
-class UIViewModel(
- nodeDB: NodeRepository,
- private val androidServiceRepository: AndroidServiceRepository,
- radioController: RadioController,
- radioInterfaceService: RadioInterfaceService,
- meshLogRepository: MeshLogRepository,
- firmwareReleaseRepository: FirmwareReleaseRepository,
- uiPreferencesDataSource: UiPreferencesDataSource,
- meshServiceNotifications: MeshServiceNotifications,
- packetRepository: PacketRepository,
- alertManager: AlertManager,
-) : BaseUIViewModel(
- nodeDB = nodeDB,
- serviceRepository = androidServiceRepository,
- radioController = radioController,
- radioInterfaceService = radioInterfaceService,
- meshLogRepository = meshLogRepository,
- firmwareReleaseRepository = firmwareReleaseRepository,
- uiPreferencesDataSource = uiPreferencesDataSource,
- meshServiceNotifications = meshServiceNotifications,
- packetRepository = packetRepository,
- alertManager = alertManager,
-) {
-
- val meshService: IMeshService?
- get() = androidServiceRepository.meshService
-
- private val _navigationDeepLink = MutableSharedFlow(replay = 1)
- val navigationDeepLink = _navigationDeepLink.asSharedFlow()
-
- fun handleNavigationDeepLink(uri: Uri) {
- _navigationDeepLink.tryEmit(uri)
- }
-
- /** Unified handler for scanned Meshtastic URIs (contacts or channels). */
- fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
- uri.dispatchMeshtasticUri(
- onContact = { setSharedContactRequested(it) },
- onChannel = { setRequestChannelSet(it) },
- onInvalid = onInvalid,
- )
- }
-}
diff --git a/app/src/main/kotlin/org/meshtastic/app/navigation/ChannelsNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/ChannelsNavigation.kt
index 1c93a0bb9..9769b404b 100644
--- a/app/src/main/kotlin/org/meshtastic/app/navigation/ChannelsNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/ChannelsNavigation.kt
@@ -20,15 +20,15 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
-import org.meshtastic.app.settings.AndroidRadioConfigViewModel
import org.meshtastic.app.ui.sharing.ChannelScreen
import org.meshtastic.core.navigation.ChannelsRoutes
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
/** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */
fun EntryProviderScope.channelsGraph(backStack: NavBackStack) {
entry {
ChannelScreen(
- radioConfigViewModel = koinViewModel(),
+ radioConfigViewModel = koinViewModel(),
onNavigate = { route -> backStack.add(route) },
onNavigateUp = { backStack.removeLastOrNull() },
)
@@ -36,7 +36,7 @@ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) {
entry {
ChannelScreen(
- radioConfigViewModel = koinViewModel(),
+ radioConfigViewModel = koinViewModel(),
onNavigate = { route -> backStack.add(route) },
onNavigateUp = { backStack.removeLastOrNull() },
)
diff --git a/app/src/main/kotlin/org/meshtastic/app/navigation/ConnectionsNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/ConnectionsNavigation.kt
index 03af52a05..58ece7359 100644
--- a/app/src/main/kotlin/org/meshtastic/app/navigation/ConnectionsNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/ConnectionsNavigation.kt
@@ -20,18 +20,18 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
-import org.meshtastic.app.settings.AndroidRadioConfigViewModel
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.connections.AndroidScannerViewModel
import org.meshtastic.feature.connections.ui.ConnectionsScreen
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. */
fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) {
entry {
ConnectionsScreen(
scanModel = koinViewModel(),
- radioConfigViewModel = koinViewModel(),
+ radioConfigViewModel = koinViewModel(),
onClickNodeChip = {
// Navigation 3 ignores back stack behavior options; we handle this by popping if necessary.
backStack.add(NodesRoutes.NodeDetailGraph(it))
@@ -44,7 +44,7 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack)
entry {
ConnectionsScreen(
scanModel = koinViewModel(),
- radioConfigViewModel = koinViewModel(),
+ radioConfigViewModel = koinViewModel(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
diff --git a/app/src/main/kotlin/org/meshtastic/app/navigation/ContactsNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/ContactsNavigation.kt
index 84d9e2cf1..ba3fa9324 100644
--- a/app/src/main/kotlin/org/meshtastic/app/navigation/ContactsNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/ContactsNavigation.kt
@@ -24,9 +24,9 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
-import org.meshtastic.app.model.UIViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.ui.component.ScrollToTopEvent
+import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.QuickChatViewModel
diff --git a/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt
index 9161b113a..24893c7a7 100644
--- a/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/NodesNavigation.kt
@@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.node.NodeMapScreen
-import org.meshtastic.app.node.AndroidMetricsViewModel
import org.meshtastic.app.ui.node.AdaptiveNodeListScreen
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes
@@ -116,7 +115,7 @@ fun EntryProviderScope.nodeDetailGraph(
}
entry { args ->
- val metricsViewModel = koinViewModel()
+ val metricsViewModel = koinViewModel()
metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen(
@@ -135,7 +134,7 @@ fun EntryProviderScope.nodeDetailGraph(
}
entry { args ->
- val metricsViewModel = koinViewModel()
+ val metricsViewModel = koinViewModel()
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
@@ -177,7 +176,7 @@ private inline fun EntryProviderScope.addNodeDetailS
crossinline getDestNum: (R) -> Int,
) {
entry { args ->
- val metricsViewModel = koinViewModel()
+ val metricsViewModel = koinViewModel()
val destNum = getDestNum(args)
metricsViewModel.setNodeId(destNum)
diff --git a/app/src/main/kotlin/org/meshtastic/app/navigation/SettingsNavigation.kt b/app/src/main/kotlin/org/meshtastic/app/navigation/SettingsNavigation.kt
index 80f1cb43c..18373aa4b 100644
--- a/app/src/main/kotlin/org/meshtastic/app/navigation/SettingsNavigation.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/navigation/SettingsNavigation.kt
@@ -26,9 +26,6 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
-import org.meshtastic.app.settings.AndroidDebugViewModel
-import org.meshtastic.app.settings.AndroidRadioConfigViewModel
-import org.meshtastic.app.settings.AndroidSettingsViewModel
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@@ -37,13 +34,16 @@ import org.meshtastic.feature.settings.AdministrationScreen
import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsScreen
+import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
+import org.meshtastic.feature.settings.debugging.DebugViewModel
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.filter.FilterSettingsViewModel
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseViewModel
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
import org.meshtastic.feature.settings.radio.component.AmbientLightingConfigScreen
import org.meshtastic.feature.settings.radio.component.AudioConfigScreen
@@ -74,8 +74,8 @@ import kotlin.reflect.KClass
@PublishedApi
@Composable
-internal fun getRadioConfigViewModel(backStack: NavBackStack): AndroidRadioConfigViewModel {
- val viewModel = koinViewModel()
+internal fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewModel {
+ val viewModel = koinViewModel()
LaunchedEffect(backStack) {
val destNum =
backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).destNum }
@@ -91,7 +91,7 @@ internal fun getRadioConfigViewModel(backStack: NavBackStack): AndroidRa
fun EntryProviderScope.settingsGraph(backStack: NavBackStack) {
entry {
SettingsScreen(
- settingsViewModel = koinViewModel(),
+ settingsViewModel = koinViewModel(),
viewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
@@ -101,7 +101,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) {
entry {
SettingsScreen(
- settingsViewModel = koinViewModel(),
+ settingsViewModel = koinViewModel(),
viewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
@@ -118,7 +118,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) {
}
entry {
- val settingsViewModel: AndroidSettingsViewModel = koinViewModel()
+ val settingsViewModel: SettingsViewModel = koinViewModel()
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = getRadioConfigViewModel(backStack),
@@ -189,7 +189,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) {
}
entry {
- val viewModel: AndroidDebugViewModel = koinViewModel()
+ val viewModel: DebugViewModel = koinViewModel()
DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
@@ -209,14 +209,14 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) {
fun EntryProviderScope.configComposable(
route: KClass,
backStack: NavBackStack,
- content: @Composable (AndroidRadioConfigViewModel) -> Unit,
+ content: @Composable (RadioConfigViewModel) -> Unit,
) {
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
}
inline fun EntryProviderScope.configComposable(
backStack: NavBackStack,
- noinline content: @Composable (AndroidRadioConfigViewModel) -> Unit,
+ noinline content: @Composable (RadioConfigViewModel) -> Unit,
) {
entry { content(getRadioConfigViewModel(backStack)) }
}
diff --git a/app/src/main/kotlin/org/meshtastic/app/node/AndroidMetricsViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/node/AndroidMetricsViewModel.kt
deleted file mode 100644
index dfa4874bb..000000000
--- a/app/src/main/kotlin/org/meshtastic/app/node/AndroidMetricsViewModel.kt
+++ /dev/null
@@ -1,113 +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.node
-
-import android.app.Application
-import android.net.Uri
-import androidx.lifecycle.SavedStateHandle
-import androidx.lifecycle.viewModelScope
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.koin.core.annotation.KoinViewModel
-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.di.CoroutineDispatchers
-import org.meshtastic.core.repository.MeshLogRepository
-import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.core.ui.util.AlertManager
-import org.meshtastic.feature.node.detail.NodeRequestActions
-import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
-import org.meshtastic.feature.node.metrics.MetricsViewModel
-import java.io.BufferedWriter
-import java.io.FileNotFoundException
-import java.io.FileWriter
-import java.text.SimpleDateFormat
-import java.util.Locale
-
-@KoinViewModel
-class AndroidMetricsViewModel(
- savedStateHandle: SavedStateHandle,
- private val app: Application,
- dispatchers: CoroutineDispatchers,
- meshLogRepository: MeshLogRepository,
- serviceRepository: ServiceRepository,
- nodeRepository: NodeRepository,
- tracerouteSnapshotRepository: TracerouteSnapshotRepository,
- nodeRequestActions: NodeRequestActions,
- alertManager: AlertManager,
- getNodeDetailsUseCase: GetNodeDetailsUseCase,
-) : MetricsViewModel(
- savedStateHandle.get("destNum") ?: 0,
- dispatchers,
- meshLogRepository,
- serviceRepository,
- nodeRepository,
- tracerouteSnapshotRepository,
- nodeRequestActions,
- alertManager,
- getNodeDetailsUseCase,
-) {
- override fun savePositionCSV(uri: Any) {
- if (uri is Uri) {
- savePositionCSVAndroid(uri)
- }
- }
-
- private fun savePositionCSVAndroid(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\"",
- )
- }
- }
- }
-
- 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" }
- }
- }
-
- override fun decodeBase64(base64: String): ByteArray =
- android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
-}
diff --git a/app/src/main/kotlin/org/meshtastic/app/settings/AndroidDebugViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/settings/AndroidDebugViewModel.kt
deleted file mode 100644
index 1fb85df8a..000000000
--- a/app/src/main/kotlin/org/meshtastic/app/settings/AndroidDebugViewModel.kt
+++ /dev/null
@@ -1,38 +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.settings
-
-import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.repository.MeshLogPrefs
-import org.meshtastic.core.repository.MeshLogRepository
-import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.ui.util.AlertManager
-import org.meshtastic.feature.settings.debugging.DebugViewModel
-import java.util.Locale
-
-@KoinViewModel
-class AndroidDebugViewModel(
- meshLogRepository: MeshLogRepository,
- nodeRepository: NodeRepository,
- meshLogPrefs: MeshLogPrefs,
- alertManager: AlertManager,
-) : DebugViewModel(meshLogRepository, nodeRepository, meshLogPrefs, alertManager) {
-
- override fun Int.toHex(length: Int): String = "!%0${length}x".format(Locale.getDefault(), this)
-
- override fun Byte.toHex(): String = "%02x".format(Locale.getDefault(), this)
-}
diff --git a/app/src/main/kotlin/org/meshtastic/app/settings/AndroidRadioConfigViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/settings/AndroidRadioConfigViewModel.kt
deleted file mode 100644
index ab57c13b8..000000000
--- a/app/src/main/kotlin/org/meshtastic/app/settings/AndroidRadioConfigViewModel.kt
+++ /dev/null
@@ -1,164 +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.settings
-
-import android.Manifest
-import android.app.Application
-import android.content.pm.PackageManager
-import android.location.Location
-import android.net.Uri
-import androidx.annotation.RequiresPermission
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.SavedStateHandle
-import androidx.lifecycle.viewModelScope
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import okio.buffer
-import okio.sink
-import okio.source
-import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
-import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
-import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
-import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
-import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
-import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
-import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
-import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
-import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
-import org.meshtastic.core.repository.AnalyticsPrefs
-import org.meshtastic.core.repository.HomoglyphPrefs
-import org.meshtastic.core.repository.LocationRepository
-import org.meshtastic.core.repository.MapConsentPrefs
-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.feature.settings.radio.RadioConfigViewModel
-import org.meshtastic.proto.Config
-import org.meshtastic.proto.DeviceProfile
-import java.io.FileOutputStream
-
-@KoinViewModel
-class AndroidRadioConfigViewModel(
- savedStateHandle: SavedStateHandle,
- private val app: Application,
- radioConfigRepository: RadioConfigRepository,
- packetRepository: PacketRepository,
- serviceRepository: ServiceRepository,
- nodeRepository: NodeRepository,
- private val locationRepository: LocationRepository,
- mapConsentPrefs: MapConsentPrefs,
- analyticsPrefs: AnalyticsPrefs,
- homoglyphEncodingPrefs: HomoglyphPrefs,
- toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
- toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
- importProfileUseCase: ImportProfileUseCase,
- exportProfileUseCase: ExportProfileUseCase,
- exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
- installProfileUseCase: InstallProfileUseCase,
- radioConfigUseCase: RadioConfigUseCase,
- adminActionsUseCase: AdminActionsUseCase,
- processRadioResponseUseCase: ProcessRadioResponseUseCase,
-) : RadioConfigViewModel(
- savedStateHandle,
- radioConfigRepository,
- packetRepository,
- serviceRepository,
- nodeRepository,
- locationRepository,
- mapConsentPrefs,
- analyticsPrefs,
- homoglyphEncodingPrefs,
- toggleAnalyticsUseCase,
- toggleHomoglyphEncodingUseCase,
- importProfileUseCase,
- exportProfileUseCase,
- exportSecurityConfigUseCase,
- installProfileUseCase,
- radioConfigUseCase,
- adminActionsUseCase,
- processRadioResponseUseCase,
-) {
- @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
- override suspend fun getCurrentLocation(): Location? = if (
- ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_FINE_LOCATION) ==
- PackageManager.PERMISSION_GRANTED
- ) {
- locationRepository.getLocations().firstOrNull()
- } else {
- null
- }
-
- override fun importProfile(uri: Any, onResult: (DeviceProfile) -> Unit) {
- if (uri is Uri) {
- viewModelScope.launch(Dispatchers.IO) {
- try {
- app.contentResolver.openInputStream(uri)?.source()?.buffer()?.use { inputStream ->
- importProfileUseCase(inputStream).onSuccess(onResult).onFailure { throw it }
- }
- } catch (ex: Exception) {
- Logger.e { "Import DeviceProfile error: ${ex.message}" }
- // Error handling simplified for this example
- }
- }
- }
- }
-
- override fun exportProfile(uri: Any, profile: DeviceProfile) {
- if (uri is Uri) {
- viewModelScope.launch {
- withContext(Dispatchers.IO) {
- try {
- app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
- FileOutputStream(parcelFileDescriptor.fileDescriptor).sink().buffer().use { outputStream ->
- exportProfileUseCase(outputStream, profile)
- .onSuccess { /* Success */ }
- .onFailure { throw it }
- }
- }
- } catch (ex: Exception) {
- Logger.e { "Can't write file error: ${ex.message}" }
- }
- }
- }
- }
- }
-
- override fun exportSecurityConfig(uri: Any, securityConfig: Config.SecurityConfig) {
- if (uri is Uri) {
- viewModelScope.launch {
- withContext(Dispatchers.IO) {
- try {
- app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
- FileOutputStream(parcelFileDescriptor.fileDescriptor).sink().buffer().use { outputStream ->
- exportSecurityConfigUseCase(outputStream, securityConfig)
- .onSuccess { /* Success */ }
- .onFailure { throw it }
- }
- }
- } catch (ex: Exception) {
- Logger.e { "Can't write security keys JSON error: ${ex.message}" }
- }
- }
- }
- }
- }
-}
diff --git a/app/src/main/kotlin/org/meshtastic/app/settings/AndroidSettingsViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/settings/AndroidSettingsViewModel.kt
deleted file mode 100644
index 61f9c2c29..000000000
--- a/app/src/main/kotlin/org/meshtastic/app/settings/AndroidSettingsViewModel.kt
+++ /dev/null
@@ -1,107 +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.settings
-
-import android.app.Application
-import android.net.Uri
-import androidx.lifecycle.viewModelScope
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import okio.BufferedSink
-import okio.buffer
-import okio.sink
-import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.common.database.DatabaseManager
-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.SetDatabaseCacheLimitUseCase
-import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
-import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
-import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
-import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
-import org.meshtastic.core.model.RadioController
-import org.meshtastic.core.repository.MeshLogPrefs
-import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.RadioConfigRepository
-import org.meshtastic.core.repository.UiPrefs
-import org.meshtastic.feature.settings.SettingsViewModel
-import java.io.FileNotFoundException
-import java.io.FileOutputStream
-
-@KoinViewModel
-@Suppress("LongParameterList")
-class AndroidSettingsViewModel(
- private val app: Application,
- radioConfigRepository: RadioConfigRepository,
- radioController: RadioController,
- nodeRepository: NodeRepository,
- uiPrefs: UiPrefs,
- buildConfigProvider: BuildConfigProvider,
- databaseManager: DatabaseManager,
- meshLogPrefs: MeshLogPrefs,
- setThemeUseCase: SetThemeUseCase,
- setLocaleUseCase: SetLocaleUseCase,
- setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
- setProvideLocationUseCase: SetProvideLocationUseCase,
- setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
- setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
- meshLocationUseCase: MeshLocationUseCase,
- exportDataUseCase: ExportDataUseCase,
- isOtaCapableUseCase: IsOtaCapableUseCase,
-) : SettingsViewModel(
- radioConfigRepository,
- radioController,
- nodeRepository,
- uiPrefs,
- buildConfigProvider,
- databaseManager,
- meshLogPrefs,
- setThemeUseCase,
- setLocaleUseCase,
- setAppIntroCompletedUseCase,
- setProvideLocationUseCase,
- setDatabaseCacheLimitUseCase,
- setMeshLogSettingsUseCase,
- meshLocationUseCase,
- exportDataUseCase,
- isOtaCapableUseCase,
-) {
- override fun saveDataCsv(uri: Any, filterPortnum: Int?) {
- if (uri is Uri) {
- viewModelScope.launch { writeToUri(uri) { writer -> performDataExport(writer, filterPortnum) } }
- }
- }
-
- private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedSink) -> Unit) {
- withContext(Dispatchers.IO) {
- try {
- app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
- FileOutputStream(parcelFileDescriptor.fileDescriptor).sink().buffer().use { writer ->
- block.invoke(writer)
- }
- }
- } catch (ex: FileNotFoundException) {
- Logger.e { "Can't write file error: ${ex.message}" }
- }
- }
- }
-}
diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
index f6828c280..80e107b5e 100644
--- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -67,7 +67,6 @@ import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig
-import org.meshtastic.app.model.UIViewModel
import org.meshtastic.app.navigation.channelsGraph
import org.meshtastic.app.navigation.connectionsGraph
import org.meshtastic.app.navigation.contactsGraph
@@ -107,6 +106,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.core.ui.util.toMessageRes
+import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.ScannerViewModel
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
index 8c1b78c47..ac3169101 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
@@ -66,6 +66,7 @@ internal fun Project.configureTestOptions() {
tasks.withType().configureEach {
// Parallelize unit tests
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
+ maxHeapSize = "2g"
// Show test results in the console
testLogging {
diff --git a/conductor/archive/deep_dive_docs_20260316/index.md b/conductor/archive/deep_dive_docs_20260316/index.md
new file mode 100644
index 000000000..aea19983d
--- /dev/null
+++ b/conductor/archive/deep_dive_docs_20260316/index.md
@@ -0,0 +1,5 @@
+# Track deep_dive_docs_20260316 Context
+
+- [Specification](./spec.md)
+- [Implementation Plan](./plan.md)
+- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/deep_dive_docs_20260316/metadata.json b/conductor/archive/deep_dive_docs_20260316/metadata.json
new file mode 100644
index 000000000..919480970
--- /dev/null
+++ b/conductor/archive/deep_dive_docs_20260316/metadata.json
@@ -0,0 +1,8 @@
+{
+ "track_id": "deep_dive_docs_20260316",
+ "type": "chore",
+ "status": "new",
+ "created_at": "2026-03-16T12:00:00Z",
+ "updated_at": "2026-03-16T12:00:00Z",
+ "description": "do a deep dive of project docs and plans in /docs - verify against actual project/codebase state, then validate against modern best practices for android, kotlin, kmp, and the dependencies used. be thorough - check all the major dependencies. Update docs and plans accordingly."
+}
\ No newline at end of file
diff --git a/conductor/archive/deep_dive_docs_20260316/plan.md b/conductor/archive/deep_dive_docs_20260316/plan.md
new file mode 100644
index 000000000..85cfc5d7c
--- /dev/null
+++ b/conductor/archive/deep_dive_docs_20260316/plan.md
@@ -0,0 +1,19 @@
+# Implementation Plan: Deep Dive & Validation of Project Docs & Plans
+
+## Phase 1: Audit & Discovery [checkpoint: 105763b]
+- [x] Task: Audit Gradle dependencies (`libs.versions.toml`) against 2026 KMP best practices (Koin, Compose, Navigation 3, etc.). baed3d6
+- [x] Task: Analyze Core Logic (`core:*`) and platform modules (Android, Desktop) for architectural alignment (MVI/Shared ViewModels). baed3d6
+- [x] Task: Review current UI and feature module implementations for Compose Multiplatform standard adherence. baed3d6
+- [x] Task: Evaluate testing patterns, coverage, and the use of shared test doubles (`core:testing`). baed3d6
+- [x] Task: Compile a list of discrepancies between current documentation/plans and the actual codebase. baed3d6
+- [x] Task: Conductor - User Manual Verification 'Phase 1: Audit & Discovery' (Protocol in workflow.md) 105763b
+
+## Phase 2: Documentation Updates [checkpoint: 7212ff1]
+- [x] Task: Update `/docs` and root-level guides (e.g., `GEMINI.md`, `kmp-status.md`, `roadmap.md`) to reflect the current, verified codebase state. baed3d6
+- [x] Task: Add explicit documentation for areas where the codebase diverges from documented best practices (flagging for future refactoring). baed3d6
+- [x] Task: Conductor - User Manual Verification 'Phase 2: Documentation Updates' (Protocol in workflow.md) 7212ff1
+
+## Phase 3: Plan Adjustment
+- [x] Task: Create new, actionable tasks in the project's main `plan.md` (roadmap.md) to address the flagged discrepancies (e.g., refactoring non-compliant Koin modules, updating deprecated APIs). baed3d6
+- [x] Task: Review and finalize the overall project roadmap and status based on the audit findings. baed3d6
+- [x] Task: Conductor - User Manual Verification 'Phase 3: Plan Adjustment' (Protocol in workflow.md) 7212ff1
\ No newline at end of file
diff --git a/conductor/archive/deep_dive_docs_20260316/spec.md b/conductor/archive/deep_dive_docs_20260316/spec.md
new file mode 100644
index 000000000..baa50bda7
--- /dev/null
+++ b/conductor/archive/deep_dive_docs_20260316/spec.md
@@ -0,0 +1,19 @@
+# Specification: Deep Dive & Validation of Project Docs & Plans
+
+## Overview
+This track involves a comprehensive review and deep dive into the project's documentation (`/docs`, `GEMINI.md`, etc.) and plans. The goal is to verify the documented state against the actual Kotlin Multiplatform (KMP) codebase and validate it against modern 2026 KMP and Android best practices. The outcome will be updated documentation reflecting the current state and flagged/planned changes for areas not following best practices.
+
+## Functional Requirements
+- **Codebase Verification:** Analyze all major areas including Core Logic (`core:*`), UI & Features (Compose Multiplatform), Dependencies (Gradle version catalogs), and Platform-specific implementations (Android, Desktop).
+- **Best Practice Validation:** Evaluate the codebase against modern standards, specifically focusing on Architecture (MVI/Shared ViewModels), Navigation (Navigation 3), Dependency Injection (Koin Annotations K2), and Testing patterns.
+- **Documentation Update:** Modify existing documentation and plans to accurately reflect the current state of the codebase and dependencies.
+- **Refactoring Proposals:** Identify and flag code or architectural decisions that deviate from best practices, outlining necessary refactoring steps in the project's plans.
+
+## Acceptance Criteria
+- All documentation in `/docs` and root-level guides accurately reflect the current codebase.
+- A comprehensive audit of major dependencies has been performed and validated against 2026 KMP standards.
+- Discrepancies between the codebase and best practices are clearly flagged and actionable tasks are added to the project plans.
+- The `plan.md` reflects the updated status and any new tasks generated from the audit.
+
+## Out of Scope
+- Direct refactoring or modification of the actual Kotlin/Android codebase during this specific track (this track focuses on documentation, planning, and flagging).
\ No newline at end of file
diff --git a/conductor/archive/extract_viewmodels_20260316/index.md b/conductor/archive/extract_viewmodels_20260316/index.md
new file mode 100644
index 000000000..aeedeb73a
--- /dev/null
+++ b/conductor/archive/extract_viewmodels_20260316/index.md
@@ -0,0 +1,5 @@
+# Track extract_viewmodels_20260316 Context
+
+- [Specification](./spec.md)
+- [Implementation Plan](./plan.md)
+- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/extract_viewmodels_20260316/metadata.json b/conductor/archive/extract_viewmodels_20260316/metadata.json
new file mode 100644
index 000000000..3ac6e636e
--- /dev/null
+++ b/conductor/archive/extract_viewmodels_20260316/metadata.json
@@ -0,0 +1,8 @@
+{
+ "track_id": "extract_viewmodels_20260316",
+ "type": "refactor",
+ "status": "new",
+ "created_at": "2026-03-16T12:00:00Z",
+ "updated_at": "2026-03-16T12:00:00Z",
+ "description": "Extract remaining 5 App-Only ViewModels (AndroidSettingsViewModel, AndroidRadioConfigViewModel, AndroidDebugViewModel, AndroidMetricsViewModel, UIViewModel) to shared KMP feature/core modules by isolating Android-specific dependencies (Uri, Location, Locale) behind abstractions."
+}
\ No newline at end of file
diff --git a/conductor/archive/extract_viewmodels_20260316/plan.md b/conductor/archive/extract_viewmodels_20260316/plan.md
new file mode 100644
index 000000000..12946e2f9
--- /dev/null
+++ b/conductor/archive/extract_viewmodels_20260316/plan.md
@@ -0,0 +1,20 @@
+# Implementation Plan: Extract Remaining App-Only ViewModels
+
+## Phase 1: Infrastructure & Abstractions [checkpoint: 89c6fd5]
+- [x] Task: Implement `MeshtasticUri` (expect/actual wrapper for `android.net.Uri`) in `core:common`. 81e5a4a
+- [x] Task: Define `FileService` and `LocationService` interfaces in `core:repository/commonMain`. 1ffa7d2
+- [x] Task: Create Android implementations for these services in `core:service/androidMain`. 1ffa7d2
+- [x] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Abstractions' (Protocol in workflow.md) 89c6fd5
+
+## Phase 2: Feature Module Extractions (Settings & Node) [checkpoint: 3ea2b2a]
+- [x] Task: Extract `AndroidSettingsViewModel` & `AndroidRadioConfigViewModel` to `feature:settings/commonMain`. 091452a
+- [x] Task: Extract `AndroidMetricsViewModel` to `feature:node/commonMain`. 52c2f6e
+- [x] Task: Extract `AndroidDebugViewModel` to `feature:settings/commonMain`. e1a0387
+- [x] Task: Update Koin modules in `feature:settings` and `feature:node` to wire the new shared ViewModels. (Handled automatically by Koin Annotations K2 plugin) e1a0387
+- [x] Task: Conductor - User Manual Verification 'Phase 2: Feature Module Extractions' (Protocol in workflow.md) 3ea2b2a
+
+## Phase 3: Core UI & Cleanup [checkpoint: c59243d]
+- [x] Task: Extract `UIViewModel` logic to `core:ui/commonMain`. 3ea2b2a
+- [x] Task: Verify the `app` module thinning progress and finalize any remaining DI cleanup in `AppKoinModule`. 3ea2b2a
+- [x] Task: Ensure all new shared ViewModels have baseline `commonTest` coverage using `core:testing` fakes. fdf34f5
+- [x] Task: Conductor - User Manual Verification 'Phase 3: Core UI & Cleanup' (Protocol in workflow.md) c59243d
\ No newline at end of file
diff --git a/conductor/archive/extract_viewmodels_20260316/spec.md b/conductor/archive/extract_viewmodels_20260316/spec.md
new file mode 100644
index 000000000..2b782bd95
--- /dev/null
+++ b/conductor/archive/extract_viewmodels_20260316/spec.md
@@ -0,0 +1,20 @@
+# Specification: Extract Remaining App-Only ViewModels
+
+## Overview
+This track aims to migrate the final 5 ViewModels currently trapped in the `app` module to their respective KMP `feature:*` or `core:*` modules. These ViewModels contain business logic that should be shared across platforms, but are currently coupled to Android-specific APIs.
+
+## Functional Requirements
+- **Isolate Dependencies:** Identify and abstract Android-specific APIs using a hybrid approach (expect/actual for low-level types and injected interfaces for services).
+- **Relocate ViewModels:** Move the core logic of these ViewModels to `commonMain` in the target modules:
+ - `SettingsViewModel` & `RadioConfigViewModel` -> `feature:settings`
+ - `DebugViewModel` -> `feature:settings`
+ - `MetricsViewModel` -> `feature:node`
+ - `UIViewModel` logic -> `core:ui`
+- **Dependency Injection:** Update Koin modules to provide platform-specific implementations of the abstracted interfaces.
+- **Maintain Parity:** Ensure existing functionality is preserved on Android while enabling these features on Desktop.
+
+## Acceptance Criteria
+- All 5 ViewModels are extracted from the `app` module and logic resides in `commonMain`.
+- `commonTest` coverage is established for the shared logic in each respective module.
+- The `app` module file count is further reduced.
+- Desktop target can instantiate and use the shared ViewModels.
\ No newline at end of file
diff --git a/conductor/tracks.md b/conductor/tracks.md
index b0b15a077..07ad7c20d 100644
--- a/conductor/tracks.md
+++ b/conductor/tracks.md
@@ -1,3 +1,4 @@
# Project Tracks
-This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
\ No newline at end of file
+This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
+
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
new file mode 100644
index 000000000..7669a66b0
--- /dev/null
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
@@ -0,0 +1,25 @@
+/*
+ * 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/util/MeshtasticUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
new file mode 100644
index 000000000..0babff5b1
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
@@ -0,0 +1,29 @@
+/*
+ * 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
+
+/**
+ * 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.
+ */
+data class MeshtasticUri(val uriString: String) {
+ override fun toString(): String = uriString
+
+ companion object {
+ fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString)
+ }
+}
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/MeshtasticUriTest.kt
new file mode 100644
index 000000000..7ca9f9fe8
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
@@ -0,0 +1,29 @@
+/*
+ * 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 MeshtasticUriTest {
+ @Test
+ fun testParseAndToString() {
+ val uriString = "content://com.example.provider/file.txt"
+ val uri = MeshtasticUri.parse(uriString)
+ assertEquals(uriString, uri.toString())
+ }
+}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index ecac2135d..06ac5016b 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -29,6 +29,7 @@ kotlin {
android {
namespace = "org.meshtastic.core.network"
androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
diff --git a/app/src/test/kotlin/org/meshtastic/app/repository/radio/NordicBleInterfaceRetryTest.kt b/core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/NordicBleInterfaceRetryTest.kt
similarity index 99%
rename from app/src/test/kotlin/org/meshtastic/app/repository/radio/NordicBleInterfaceRetryTest.kt
rename to core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/NordicBleInterfaceRetryTest.kt
index 90840450f..11e02d632 100644
--- a/app/src/test/kotlin/org/meshtastic/app/repository/radio/NordicBleInterfaceRetryTest.kt
+++ b/core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/NordicBleInterfaceRetryTest.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.repository.radio
+package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
diff --git a/app/src/test/kotlin/org/meshtastic/app/repository/radio/NordicBleInterfaceTest.kt b/core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/NordicBleInterfaceTest.kt
similarity index 99%
rename from app/src/test/kotlin/org/meshtastic/app/repository/radio/NordicBleInterfaceTest.kt
rename to core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/NordicBleInterfaceTest.kt
index faf62d3d4..2981ea7d4 100644
--- a/app/src/test/kotlin/org/meshtastic/app/repository/radio/NordicBleInterfaceTest.kt
+++ b/core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/NordicBleInterfaceTest.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.repository.radio
+package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
diff --git a/app/src/test/kotlin/org/meshtastic/app/repository/radio/StreamInterfaceTest.kt b/core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt
similarity index 98%
rename from app/src/test/kotlin/org/meshtastic/app/repository/radio/StreamInterfaceTest.kt
rename to core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt
index 865969340..ac015e133 100644
--- a/app/src/test/kotlin/org/meshtastic/app/repository/radio/StreamInterfaceTest.kt
+++ b/core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.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.repository.radio
+package org.meshtastic.core.network.radio
import io.mockk.confirmVerified
import io.mockk.mockk
diff --git a/app/src/test/kotlin/org/meshtastic/app/repository/radio/TCPInterfaceTest.kt b/core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/TCPInterfaceTest.kt
similarity index 97%
rename from app/src/test/kotlin/org/meshtastic/app/repository/radio/TCPInterfaceTest.kt
rename to core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/TCPInterfaceTest.kt
index be2d690b1..814ac1fd8 100644
--- a/app/src/test/kotlin/org/meshtastic/app/repository/radio/TCPInterfaceTest.kt
+++ b/core/network/src/androidUnitTest/kotlin/org/meshtastic/core/network/radio/TCPInterfaceTest.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.repository.radio
+package org.meshtastic.core.network.radio
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
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
new file mode 100644
index 000000000..dca2a6bf3
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt
@@ -0,0 +1,39 @@
+/*
+ * 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
+
+import okio.BufferedSink
+import okio.BufferedSource
+import org.meshtastic.core.common.util.MeshtasticUri
+
+/**
+ * Abstracts file system operations (like reading from or writing to URIs) so that ViewModels can remain
+ * platform-independent.
+ */
+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
+
+ /**
+ * 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
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocationService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocationService.kt
new file mode 100644
index 000000000..133317de6
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocationService.kt
@@ -0,0 +1,29 @@
+/*
+ * 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
+
+/**
+ * Abstracts high-level location requests (such as one-off current location) that may require platform-specific
+ * permission checks or hardware interactions.
+ */
+interface LocationService {
+ /**
+ * Requests the current location, if permissions and hardware allow. Returns null if unavailable or if permissions
+ * are not granted.
+ */
+ suspend fun getCurrentLocation(): Location?
+}
diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
index 03b80191b..89476bb13 100644
--- a/core/service/build.gradle.kts
+++ b/core/service/build.gradle.kts
@@ -27,6 +27,7 @@ kotlin {
android {
namespace = "org.meshtastic.core.service"
androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
@@ -42,7 +43,20 @@ kotlin {
implementation(libs.kermit)
}
- androidMain.dependencies { api(projects.core.api) }
+ androidMain.dependencies {
+ api(projects.core.api)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.koin.android)
+ implementation(libs.koin.androidx.workmanager)
+ }
+
+ androidUnitTest.dependencies {
+ implementation(libs.robolectric)
+ implementation(libs.androidx.test.core)
+ implementation(libs.androidx.work.testing)
+ }
commonTest.dependencies {
implementation(kotlin("test"))
diff --git a/core/service/detekt-baseline.xml b/core/service/detekt-baseline.xml
index c373eea43..f52cb1635 100644
--- a/core/service/detekt-baseline.xml
+++ b/core/service/detekt-baseline.xml
@@ -1,5 +1,8 @@
-
+
+ TooGenericExceptionCaught:AndroidFileService.kt$AndroidFileService$e: Exception
+ TooGenericExceptionCaught:JvmFileService.kt$JvmFileService$e: Exception
+
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
new file mode 100644
index 000000000..010fcdc89
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.service
+
+import android.app.Application
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okio.BufferedSink
+import okio.BufferedSource
+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.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) {
+ try {
+ val pfd = context.contentResolver.openFileDescriptor(uri.toAndroidUri(), "wt")
+ if (pfd == null) {
+ Logger.e { "Failed to obtain file descriptor for URI: $uri" }
+ return@withContext false
+ }
+ pfd.use { descriptor ->
+ FileOutputStream(descriptor.fileDescriptor).sink().buffer().use { sink -> block(sink) }
+ }
+ true
+ } catch (e: Exception) {
+ Logger.e(e) { "Failed to write to URI: $uri" }
+ false
+ }
+ }
+
+ override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
+ withContext(Dispatchers.IO) {
+ try {
+ val success =
+ context.contentResolver.openInputStream(uri.toAndroidUri())?.use { inputStream ->
+ inputStream.source().buffer().use { source -> block(source) }
+ true
+ } ?: false
+ success
+ } catch (e: Exception) {
+ Logger.e(e) { "Failed to read from URI: $uri" }
+ false
+ }
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidLocationService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidLocationService.kt
new file mode 100644
index 000000000..d28d59fc6
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidLocationService.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.service
+
+import android.Manifest
+import android.app.Application
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.flow.firstOrNull
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.Location
+import org.meshtastic.core.repository.LocationRepository
+import org.meshtastic.core.repository.LocationService
+
+@Single
+class AndroidLocationService(private val context: Application, private val locationRepository: LocationRepository) :
+ LocationService {
+
+ override suspend fun getCurrentLocation(): Location? {
+ val hasPermission =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED
+
+ if (!hasPermission) {
+ return null
+ }
+
+ return locationRepository.getLocations().firstOrNull()
+ }
+}
diff --git a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt
new file mode 100644
index 000000000..89a006d9a
--- /dev/null
+++ b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.service
+
+import android.app.Application
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class AndroidFileServiceTest {
+ @Test
+ fun testInitialization() = runTest {
+ val mockContext = mockk(relaxed = true)
+ val service = AndroidFileService(mockContext)
+ assertNotNull(service)
+ }
+}
diff --git a/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt
new file mode 100644
index 000000000..50d308dfc
--- /dev/null
+++ b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/AndroidLocationServiceTest.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.service
+
+import android.app.Application
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.meshtastic.core.repository.LocationRepository
+
+class AndroidLocationServiceTest {
+ @Test
+ fun testInitialization() = runTest {
+ val mockContext = mockk(relaxed = true)
+ val mockRepo = mockk(relaxed = true)
+ val service = AndroidLocationService(mockContext, mockRepo)
+ assertNotNull(service)
+ }
+}
diff --git a/app/src/test/kotlin/org/meshtastic/app/messaging/domain/worker/SendMessageWorkerTest.kt b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt
similarity index 99%
rename from app/src/test/kotlin/org/meshtastic/app/messaging/domain/worker/SendMessageWorkerTest.kt
rename to core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt
index 3f0f10068..9ee55f624 100644
--- a/app/src/test/kotlin/org/meshtastic/app/messaging/domain/worker/SendMessageWorkerTest.kt
+++ b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.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.messaging.domain.worker
+package org.meshtastic.core.service
import android.content.Context
import androidx.test.core.app.ApplicationProvider
diff --git a/app/src/test/kotlin/org/meshtastic/app/service/ServiceBroadcastsTest.kt b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt
similarity index 98%
rename from app/src/test/kotlin/org/meshtastic/app/service/ServiceBroadcastsTest.kt
rename to core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt
index 0f90d22d2..c9200f667 100644
--- a/app/src/test/kotlin/org/meshtastic/app/service/ServiceBroadcastsTest.kt
+++ b/core/service/src/androidUnitTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.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.service
+package org.meshtastic.core.service
import android.app.Application
import android.content.Context
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
new file mode 100644
index 000000000..8f8e08d45
--- /dev/null
+++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.service
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okio.BufferedSink
+import okio.BufferedSource
+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.repository.FileService
+import java.io.File
+
+@Single
+class JvmFileService : FileService {
+ override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
+ withContext(Dispatchers.IO) {
+ try {
+ // Treat uriString as a local file path
+ val file = File(uri.uriString)
+ file.parentFile?.mkdirs()
+ file.sink().buffer().use { sink -> block(sink) }
+ true
+ } catch (e: Exception) {
+ Logger.e(e) { "Failed to write to URI: $uri" }
+ false
+ }
+ }
+
+ override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
+ withContext(Dispatchers.IO) {
+ try {
+ val file = File(uri.uriString)
+ file.source().buffer().use { source -> block(source) }
+ true
+ } catch (e: Exception) {
+ Logger.e(e) { "Failed to read from URI: $uri" }
+ false
+ }
+ }
+}
diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmLocationService.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmLocationService.kt
new file mode 100644
index 000000000..7e0124dab
--- /dev/null
+++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmLocationService.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.service
+
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.Location
+import org.meshtastic.core.repository.LocationService
+
+@Single
+class JvmLocationService : LocationService {
+ override suspend fun getCurrentLocation(): Location? {
+ // Location services on JVM/Desktop are currently stubbed
+ return null
+ }
+}
diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmFileServiceTest.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmFileServiceTest.kt
new file mode 100644
index 000000000..46926a4e0
--- /dev/null
+++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmFileServiceTest.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.service
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import org.meshtastic.core.common.util.MeshtasticUri
+
+class JvmFileServiceTest {
+ @Test
+ fun testWriteAndRead() = runTest {
+ val service = JvmFileService()
+ // Just verify it doesn't crash on invalid paths for now.
+ val result = service.read(MeshtasticUri("invalid_file_path.txt")) {}
+ assertFalse(result)
+ }
+}
diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmLocationServiceTest.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmLocationServiceTest.kt
new file mode 100644
index 000000000..5db50f233
--- /dev/null
+++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmLocationServiceTest.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.service
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class JvmLocationServiceTest {
+ @Test
+ fun testGetCurrentLocationReturnsNullOnJvm() = runTest {
+ val service = JvmLocationService()
+ val location = service.getCurrentLocation()
+ assertNull(location)
+ }
+}
diff --git a/core/testing/README.md b/core/testing/README.md
index b55ab37c4..1307f107b 100644
--- a/core/testing/README.md
+++ b/core/testing/README.md
@@ -43,6 +43,12 @@ The `:core:testing` module provides lightweight, reusable test doubles (fakes, b
(etc.) (etc.)
```
+### Target Compatibility Warning (March 2026 Audit)
+
+- **MockK in commonMain:** This module includes `api(libs.mockk)` in `commonMain`. While this works for the current `jvm()` and `android()` targets, **MockK does not natively support Kotlin/Native (iOS)**.
+- **Future-Proofing:** If an iOS target is added, tests in `commonTest` that rely on MockK will fail to compile for iOS.
+- **Recommendation:** Favor manual fakes (like `FakeNodeRepository`) in `commonMain` and limit `mockk` usage to `androidUnitTest` or `jvmTest` where possible to maintain pure KMP portability.
+
### Key Design Rules
1. **`:core:testing` has NO dependencies on heavy modules**: It only depends on:
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/BaseUIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
similarity index 90%
rename from core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/BaseUIViewModel.kt
rename to core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
index fb002c018..2341a3734 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/BaseUIViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
@@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.mapNotNull
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.MeshtasticUri
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
@@ -42,6 +44,7 @@ import org.meshtastic.core.model.RadioController
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.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
@@ -62,11 +65,11 @@ import org.meshtastic.proto.SharedContact
* Shared base for the application-level ViewModel.
*
* Contains all platform-independent state and actions (themes, alerts, connection state, firmware checks, traceroute,
- * shared contacts, channel sets, unread counts, etc.). The thin Android adapter [org.meshtastic.app.model.UIViewModel]
- * extends this class and adds the deep-link / URI boundary that requires `android.net.Uri`.
+ * shared contacts, channel sets, unread counts, etc.).
*/
+@KoinViewModel
@Suppress("LongParameterList", "TooManyFunctions")
-abstract class BaseUIViewModel(
+class UIViewModel(
private val nodeDB: NodeRepository,
protected val serviceRepository: ServiceRepository,
private val radioController: RadioController,
@@ -79,6 +82,23 @@ abstract class BaseUIViewModel(
private val alertManager: AlertManager,
) : ViewModel() {
+ private val _navigationDeepLink = MutableSharedFlow(replay = 1)
+ val navigationDeepLink = _navigationDeepLink.asSharedFlow()
+
+ fun handleNavigationDeepLink(uri: MeshtasticUri) {
+ _navigationDeepLink.tryEmit(uri)
+ }
+
+ /** Unified handler for scanned Meshtastic URIs (contacts or channels). */
+ fun handleScannedUri(uri: MeshtasticUri, onInvalid: () -> Unit) {
+ org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
+ .dispatchMeshtasticUri(
+ onContact = { setSharedContactRequested(it) },
+ onChannel = { setRequestChannelSet(it) },
+ onInvalid = onInvalid,
+ )
+ }
+
val theme: StateFlow = uiPreferencesDataSource.theme
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
@@ -186,7 +206,7 @@ abstract class BaseUIViewModel(
}
.launchIn(viewModelScope)
- Logger.d { "BaseUIViewModel created" }
+ Logger.d { "UIViewModel created" }
}
private val _sharedContactRequested: MutableStateFlow = MutableStateFlow(null)
@@ -223,7 +243,7 @@ abstract class BaseUIViewModel(
override fun onCleared() {
super.onCleared()
- Logger.d { "BaseUIViewModel cleared" }
+ Logger.d { "UIViewModel cleared" }
}
val tracerouteResponse: Flow
diff --git a/docs/kmp-status.md b/docs/kmp-status.md
index 6d4de8911..de16d625b 100644
--- a/docs/kmp-status.md
+++ b/docs/kmp-status.md
@@ -1,6 +1,6 @@
# KMP Migration Status
-> Last updated: 2026-03-13
+> Last updated: 2026-03-16
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/).
@@ -93,10 +93,9 @@ Working Compose Desktop application with:
Based on the latest codebase investigation, the following steps are proposed to complete the multi-target and iOS-readiness migrations:
-1. **Extract remaining App-Only ViewModels:** Migrate the 5 remaining `Android*ViewModel`s by isolating their Android-specific dependencies (e.g., `android.net.Uri` for file I/O, Location permissions) behind expect/actual or injected interface abstractions.
-2. **Wire Desktop Features:** Complete desktop UI wiring for `feature:intro` and implement a shared fallback for `feature:map` (which is currently a placeholder on desktop).
-3. **Decouple Firmware DFU:** `feature:firmware` relies on Android-only DFU libraries. Evaluate wrapping this in a shared KMP interface or extracting it into a separate plugin to allow the core `feature:firmware` module to be fully utilized on desktop/iOS.
-4. **Prepare for iOS Target:** Set up an initial skeleton Xcode project to start validating `commonMain` compilation on Kotlin/Native (iOS).
+1. **Wire Desktop Features:** Complete desktop UI wiring for `feature:intro` and implement a shared fallback for `feature:map` (which is currently a placeholder on desktop).
+2. **Decouple Firmware DFU:** `feature:firmware` relies on Android-only DFU libraries. Evaluate wrapping this in a shared KMP interface or extracting it into a separate plugin to allow the core `feature:firmware` module to be fully utilized on desktop/iOS.
+3. **Prepare for iOS Target:** Set up an initial skeleton Xcode project to start validating `commonMain` compilation on Kotlin/Native (iOS).
## Key Architecture Decisions
@@ -123,17 +122,14 @@ Based on the latest codebase investigation, the following steps are proposed to
## Remaining App-Only ViewModels
-Only ViewModels with **genuine Android-specific logic** retain wrappers:
-
-| ViewModel | Android-Specific Reason |
-|---|---|
-| `AndroidSettingsViewModel` | File I/O via `android.net.Uri` |
-| `AndroidRadioConfigViewModel` | Location permissions, file I/O |
-| `AndroidDebugViewModel` | `Locale`-aware hex formatting |
-| `AndroidMetricsViewModel` | CSV export via `android.net.Uri` |
-| `UIViewModel` | Deep links via `android.net.Uri`, `IMeshService` |
+All major ViewModels have now been extracted to `commonMain` and no longer rely on Android-specific subclasses. Platform-specific dependencies (like `android.net.Uri` or Location permissions) have been successfully isolated behind injected `core:repository` interfaces (e.g., `FileService`, `LocationService`).
Extracted to shared `commonMain` (no longer app-only):
+- `SettingsViewModel` → `feature:settings/commonMain`
+- `RadioConfigViewModel` → `feature:settings/commonMain`
+- `DebugViewModel` → `feature:settings/commonMain`
+- `MetricsViewModel` → `feature:node/commonMain`
+- `UIViewModel` → `core:ui/commonMain`
- `ChannelViewModel` → `feature:settings/commonMain`
- `NodeMapViewModel` → `feature:map/commonMain`
diff --git a/docs/roadmap.md b/docs/roadmap.md
index f635cae7e..4174c7562 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap
-> Last updated: 2026-03-12
+> Last updated: 2026-03-16
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).
@@ -85,10 +85,13 @@ These items address structural gaps identified in the March 2026 architecture re
## Medium-Term Priorities (60 days)
-1. **App module thinning** — 63 files remaining (down from 90). Extracted ChannelViewModel, NodeMapViewModel, NodeContextMenu, EmptyDetailPlaceholder to shared modules. Remaining: extract service/worker/radio files from `app` to `core:service/androidMain` and `core:network/androidMain`
+1. **App module thinning** — Extracted ChannelViewModel, NodeMapViewModel, NodeContextMenu, EmptyDetailPlaceholder to shared modules.
+ - ✅ **Done:** Extracted remaining 5 ViewModels: `SettingsViewModel`, `RadioConfigViewModel`, `DebugViewModel`, `MetricsViewModel`, `UIViewModel` to shared KMP modules.
+ - **Next:** Extract service/worker/radio files from `app` to `core:service/androidMain` and `core:network/androidMain`.
2. **Serial/USB transport** — direct radio connection on Desktop via jSerialComm
3. **MQTT transport** — cloud relay operation (KMP, benefits all targets)
-4. **Desktop ViewModel auto-wiring** — ✅ Done: ensured Koin K2 Compiler Plugin generates ViewModel modules for JVM target; eliminated manual wiring in `DesktopKoinModule`
+4. **Evaluate KMP-native mocking** — Evaluate `mockative` or similar to replace `mockk` in `commonMain` of `core:testing` for iOS readiness.
+5. **Desktop ViewModel auto-wiring** — ✅ Done: ensured Koin K2 Compiler Plugin generates ViewModel modules for JVM target; eliminated manual wiring in `DesktopKoinModule`
5. **KMP charting** — ✅ Done: Vico charts migrated to `feature:node/commonMain` using KMP artifacts; desktop wires them directly
6. **Navigation contract extraction** — ✅ Done: shared `TopLevelDestination` enum in `core:navigation`; icon mapping in `core:ui`; parity tests in place. Both shells derive from the same source of truth.
7. **Dependency stabilization** — track stable releases for CMP, Koin, Lifecycle, Nav3
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
index 3086e8d1e..318a6431f 100644
--- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
+++ b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.messaging.ui.contact
-import android.net.Uri
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
@@ -34,6 +33,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
@@ -57,7 +57,7 @@ fun AdaptiveContactsScreen(
scrollToTopEvents: Flow,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
- onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
+ onHandleScannedUri: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
index a623608e7..e002459c7 100644
--- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
+++ b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.messaging.ui.contact
-import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -64,7 +63,9 @@ 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.nowMillis
+import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.util.TimeConstants
@@ -118,7 +119,7 @@ fun ContactsScreen(
onNavigateToShare: () -> Unit,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
- onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
+ onHandleScannedUri: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel,
@@ -256,7 +257,7 @@ fun ContactsScreen(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uriString ->
- onHandleScannedUri(uriString.toUri()) {
+ onHandleScannedUri(uriString.toUri().toMeshtasticUri()) {
scope.launch { context.showToast(Res.string.channel_invalid) }
}
},
diff --git a/feature/node/detekt-baseline.xml b/feature/node/detekt-baseline.xml
index c71bc233d..2a7d88912 100644
--- a/feature/node/detekt-baseline.xml
+++ b/feature/node/detekt-baseline.xml
@@ -2,10 +2,10 @@
- CyclomaticComplexMethod:CompassViewModel.kt$CompassViewModel$@Suppress("ReturnCount") private fun calculatePositionalAccuracyMeters(): Float?
- CyclomaticComplexMethod:NodeDetailActions.kt$NodeDetailActions$fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction)
- CyclomaticComplexMethod:NodeDetailViewModel.kt$NodeDetailViewModel$fun handleNodeMenuAction(action: NodeMenuAction)
MagicNumber:CompassViewModel.kt$CompassViewModel$180.0
+ MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5
+ MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7
+ MaxLineLength:MetricsViewModel.kt$MetricsViewModel$"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n"
TooGenericExceptionCaught:MetricsViewModel.kt$MetricsViewModel$e: Exception
TooGenericExceptionCaught:NodeManagementActions.kt$NodeManagementActions$ex: Exception
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
index 3b491e3f4..78cc07fa8 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.save
@@ -119,7 +120,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val exportPositionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
- it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) }
+ it.data?.data?.let { uri -> viewModel.savePositionCSV(uri.toMeshtasticUri()) }
}
}
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 a71b428c7..438afcaa7 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
@@ -35,9 +35,14 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.datetime.Instant
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+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.nowSeconds
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.di.CoroutineDispatchers
@@ -46,6 +51,7 @@ 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.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
@@ -81,6 +87,7 @@ open class MetricsViewModel(
private val nodeRequestActions: NodeRequestActions,
private val alertManager: AlertManager,
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
+ private val fileService: FileService,
) : ViewModel() {
private val nodeIdFromRoute: Int?
@@ -315,8 +322,35 @@ open class MetricsViewModel(
Logger.d { "MetricsViewModel cleared" }
}
- open fun savePositionCSV(uri: Any) {
- // To be implemented in platform-specific subclass
+ fun savePositionCSV(uri: MeshtasticUri) {
+ viewModelScope.launch(dispatchers.main) {
+ val positions = state.value.positionLogs
+ 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) * 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
+ // Kotlin string format is available in common code on 1.9.20+ via String.format,
+ // but we can just do basic string manipulation if needed.
+ val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
+
+ sink.writeUtf8(
+ "$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n",
+ )
+ }
+ }
+ }
}
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
@@ -347,8 +381,5 @@ open class MetricsViewModel(
return null
}
- protected open fun decodeBase64(base64: String): ByteArray {
- // To be overridden in platform-specific subclass or use KMP library
- return ByteArray(0)
- }
+ protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0)
}
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
new file mode 100644
index 000000000..892c70b59
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
@@ -0,0 +1,155 @@
+/*
+ * 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.feature.node.metrics
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import okio.Buffer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.FileService
+import org.meshtastic.core.repository.MeshLogRepository
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.ui.util.AlertManager
+import org.meshtastic.feature.node.detail.NodeDetailUiState
+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.proto.Position
+
+class MetricsViewModelTest {
+ private val dispatchers =
+ CoroutineDispatchers(
+ main = kotlinx.coroutines.Dispatchers.Unconfined,
+ io = kotlinx.coroutines.Dispatchers.Unconfined,
+ default = kotlinx.coroutines.Dispatchers.Unconfined,
+ )
+ private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
+ private val serviceRepository: ServiceRepository = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val tracerouteSnapshotRepository: TracerouteSnapshotRepository = mockk(relaxed = true)
+ private val nodeRequestActions: NodeRequestActions = mockk(relaxed = true)
+ private val alertManager: AlertManager = mockk(relaxed = true)
+ private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mockk(relaxed = true)
+ private val fileService: FileService = mockk(relaxed = true)
+
+ private lateinit var viewModel: MetricsViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(dispatchers.main)
+
+ viewModel =
+ MetricsViewModel(
+ destNum = 1234,
+ dispatchers = dispatchers,
+ meshLogRepository = meshLogRepository,
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ tracerouteSnapshotRepository = tracerouteSnapshotRepository,
+ nodeRequestActions = nodeRequestActions,
+ alertManager = alertManager,
+ getNodeDetailsUseCase = getNodeDetailsUseCase,
+ fileService = fileService,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test fun testInitialization() = runTest { assertNotNull(viewModel) }
+
+ @Test
+ fun testSavePositionCSV() = runTest {
+ val testPosition =
+ Position(
+ latitude_i = 123456789,
+ longitude_i = -987654321,
+ altitude = 100,
+ sats_in_view = 5,
+ ground_speed = 10,
+ ground_track = 123456,
+ time = 1700000000,
+ )
+
+ coEvery { getNodeDetailsUseCase(any()) } returns
+ flowOf(NodeDetailUiState(metricsState = MetricsState(positionLogs = listOf(testPosition))))
+
+ // Re-init view model so it picks up the mocked flow
+ viewModel =
+ MetricsViewModel(
+ destNum = 1234,
+ dispatchers = dispatchers,
+ meshLogRepository = meshLogRepository,
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ tracerouteSnapshotRepository = tracerouteSnapshotRepository,
+ nodeRequestActions = nodeRequestActions,
+ alertManager = alertManager,
+ getNodeDetailsUseCase = getNodeDetailsUseCase,
+ fileService = fileService,
+ )
+
+ // Wait for state to populate
+ val collectionJob = backgroundScope.launch { viewModel.state.collect {} }
+ kotlinx.coroutines.yield()
+ advanceUntilIdle()
+
+ val uri = MeshtasticUri("content://test")
+ val blockSlot = slot Unit>()
+
+ coEvery { fileService.write(uri, capture(blockSlot)) } returns true
+
+ viewModel.savePositionCSV(uri)
+
+ advanceUntilIdle()
+
+ coVerify { fileService.write(uri, any()) }
+
+ val buffer = Buffer()
+ blockSlot.captured.invoke(buffer)
+
+ val csvOutput = buffer.readUtf8()
+ assertEquals(
+ "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n",
+ csvOutput.substringBefore("\n") + "\n",
+ )
+ assert(csvOutput.contains("12.345")) { "Missing latitude in $csvOutput" }
+ assert(csvOutput.contains("-98.765")) { "Missing longitude in $csvOutput" }
+ assert(csvOutput.contains("\"100\",\"5\",\"10\",\"1.23\"\n")) { "Missing rest in $csvOutput" }
+
+ collectionJob.cancel()
+ }
+}
diff --git a/feature/settings/detekt-baseline.xml b/feature/settings/detekt-baseline.xml
index 70bf11c60..348ed6629 100644
--- a/feature/settings/detekt-baseline.xml
+++ b/feature/settings/detekt-baseline.xml
@@ -2,16 +2,10 @@
- CyclomaticComplexMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
- CyclomaticComplexMethod:ExternalNotificationConfigItemList.kt$@Suppress("LongMethod", "TooGenericExceptionCaught") @Composable fun ExternalNotificationConfigScreen( onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: RadioConfigViewModel, )
- CyclomaticComplexMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
CyclomaticComplexMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)
LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
- LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
- LongMethod:DeviceConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
- LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
@@ -22,6 +16,7 @@
LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
LongMethod:UserConfigItemList.kt$@Composable fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
MagicNumber:Debug.kt$3
+ MagicNumber:DebugViewModel.kt$DebugViewModel$16
MagicNumber:DebugViewModel.kt$DebugViewModel$8
MagicNumber:EditChannelDialog.kt$16
MagicNumber:EditChannelDialog.kt$32
@@ -29,9 +24,9 @@
MagicNumber:EditDeviceProfileDialog.kt$ProfileField.CONFIG$4
MagicNumber:EditDeviceProfileDialog.kt$ProfileField.FIXED_POSITION$6
MagicNumber:EditDeviceProfileDialog.kt$ProfileField.MODULE_CONFIG$5
- MagicNumber:PacketResponseStateDialog.kt$100
ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)
TooGenericExceptionCaught:DebugViewModel.kt$DebugViewModel$e: Exception
+ TooGenericExceptionCaught:RadioConfigViewModel.kt$RadioConfigViewModel$ex: Exception
TooManyFunctions:RadioConfigViewModel.kt$RadioConfigViewModel : ViewModel
UnusedPrivateProperty:RadioConfigViewModel.kt$RadioConfigViewModel$private val locationRepository: LocationRepository
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 4150417da..29a71be9a 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
@@ -41,6 +41,7 @@ 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.SettingsRoutes
import org.meshtastic.core.resources.Res
@@ -97,14 +98,16 @@ fun SettingsScreen(
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
showEditDeviceProfileDialog = true
- it.data?.data?.let { uri -> viewModel.importProfile(uri) { profile -> deviceProfile = profile } }
+ it.data?.data?.let { uri ->
+ viewModel.importProfile(uri.toMeshtasticUri()) { profile -> deviceProfile = profile }
+ }
}
}
val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
- it.data?.data?.let { uri -> viewModel.exportProfile(uri, deviceProfile!!) }
+ it.data?.data?.let { uri -> viewModel.exportProfile(uri.toMeshtasticUri(), deviceProfile!!) }
}
}
@@ -234,7 +237,7 @@ fun SettingsScreen(
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
nodeShortName = ourNode?.user?.short_name ?: "",
- onExportData = { settingsViewModel.saveDataCsv(it) },
+ onExportData = { settingsViewModel.saveDataCsv(it.toMeshtasticUri()) },
)
AppInfoSection(
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt
index 018f128fc..9ca007f00 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigItemList.kt
@@ -256,7 +256,7 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
enabled = state.connected && !isLocationRequiredAndDisabled,
onClick = {
@SuppressLint("MissingPermission")
- coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() as? Location }
+ coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() }
},
) {
Text(text = stringResource(Res.string.position_config_set_fixed_from_phone))
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt
index 94627644f..440166010 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt
@@ -40,6 +40,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.model.util.encodeToString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.admin_key
@@ -94,7 +95,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
- it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri, securityConfig) }
+ it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), 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 262959da7..eba0bb257 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
@@ -30,6 +30,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.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
@@ -42,6 +43,7 @@ import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -51,7 +53,7 @@ import org.meshtastic.proto.LocalConfig
@KoinViewModel
@Suppress("LongParameterList", "TooManyFunctions")
-open class SettingsViewModel(
+class SettingsViewModel(
radioConfigRepository: RadioConfigRepository,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
@@ -68,6 +70,7 @@ open class SettingsViewModel(
private val meshLocationUseCase: MeshLocationUseCase,
private val exportDataUseCase: ExportDataUseCase,
private val isOtaCapableUseCase: IsOtaCapableUseCase,
+ private val fileService: FileService,
) : ViewModel() {
val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
@@ -161,11 +164,11 @@ open class SettingsViewModel(
* @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
- open fun saveDataCsv(uri: Any, filterPortnum: Int? = null) {
- // To be implemented in platform-specific subclass
+ fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) {
+ viewModelScope.launch { fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } }
}
- protected suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) {
+ private suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) {
val myNodeNum = myNodeNum ?: return
exportDataUseCase(writer, myNodeNum, filterPortnum)
}
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 ade26c610..bca6235b7 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
@@ -214,7 +214,7 @@ class LogFilterManager {
@KoinViewModel
@Suppress("TooManyFunctions")
-open class DebugViewModel(
+class DebugViewModel(
private val meshLogRepository: MeshLogRepository,
private val nodeRepository: NodeRepository,
private val meshLogPrefs: MeshLogPrefs,
@@ -395,10 +395,7 @@ open class DebugViewModel(
return false
}
- protected open fun Int.toHex(length: Int): String {
- // Platform specific hex implementation
- return "!$this"
- }
+ private fun Int.toHex(length: Int): String = "!" + this.toUInt().toString(16).padStart(length, '0')
fun requestDeleteAllLogs() {
alertManager.showAlert(
@@ -498,7 +495,7 @@ open class DebugViewModel(
}
}
- protected open fun Byte.toHex(): String = this.toString()
+ private fun Byte.toHex(): String = this.toUByte().toString(16).padStart(2, '0')
private fun formatNodeWithShortName(nodeNum: Int): String {
val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user
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 5d7c5951b..7e7b09e0c 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,6 +31,7 @@ import kotlinx.coroutines.launch
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.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
@@ -46,8 +47,10 @@ import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.repository.AnalyticsPrefs
+import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.LocationRepository
+import org.meshtastic.core.repository.LocationService
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -113,6 +116,8 @@ open class RadioConfigViewModel(
private val radioConfigUseCase: RadioConfigUseCase,
private val adminActionsUseCase: AdminActionsUseCase,
private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
+ private val locationService: LocationService,
+ private val fileService: FileService,
) : ViewModel() {
var analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
@@ -150,7 +155,8 @@ open class RadioConfigViewModel(
val currentDeviceProfile
get() = _currentDeviceProfile.value
- open suspend fun getCurrentLocation(): Any? = null
+ open suspend fun getCurrentLocation(): org.meshtastic.core.repository.Location? =
+ locationService.getCurrentLocation()
init {
combine(destNumFlow, nodeRepository.nodeDBbyNum) { id, nodes -> nodes[id] ?: nodes.values.firstOrNull() }
@@ -363,16 +369,42 @@ open class RadioConfigViewModel(
viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
}
- open fun importProfile(uri: Any, onResult: (DeviceProfile) -> Unit) {
- // To be implemented in platform-specific subclass
+ 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}" }
+ }
+ }
}
- open fun exportProfile(uri: Any, profile: DeviceProfile) {
- // To be implemented in platform-specific subclass
+ 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}" }
+ }
+ }
}
- open fun exportSecurityConfig(uri: Any, securityConfig: Config.SecurityConfig) {
- // To be implemented in platform-specific subclass
+ 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}" }
+ }
+ }
}
fun installProfile(protobuf: DeviceProfile) {
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 dfa71983d..1e94d311e 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
@@ -80,6 +80,7 @@ class SettingsViewModelTest {
meshLocationUseCase = mockk(relaxed = true),
exportDataUseCase = mockk(relaxed = true),
isOtaCapableUseCase = mockk(relaxed = true),
+ fileService = mockk(relaxed = true),
)
}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
similarity index 100%
rename from feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
similarity index 97%
rename from feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
index 676fb9a0c..7bb3ed283 100644
--- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
@@ -83,6 +83,8 @@ class RadioConfigViewModelTest {
private val radioConfigUseCase: RadioConfigUseCase = mockk(relaxed = true)
private val adminActionsUseCase: AdminActionsUseCase = mockk(relaxed = true)
private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mockk(relaxed = true)
+ private val locationService: org.meshtastic.core.repository.LocationService = mockk(relaxed = true)
+ private val fileService: org.meshtastic.core.repository.FileService = mockk(relaxed = true)
private lateinit var viewModel: RadioConfigViewModel
@@ -110,7 +112,6 @@ class RadioConfigViewModelTest {
private fun createViewModel() = RadioConfigViewModel(
savedStateHandle = SavedStateHandle(),
- app = mockk(),
radioConfigRepository = radioConfigRepository,
packetRepository = packetRepository,
serviceRepository = serviceRepository,
@@ -128,6 +129,8 @@ class RadioConfigViewModelTest {
radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase,
processRadioResponseUseCase = processRadioResponseUseCase,
+ locationService = locationService,
+ fileService = fileService,
)
@Test