feat: Complete ViewModel extraction and update documentation (#4817)

This commit is contained in:
James Rich 2026-03-16 15:05:50 -05:00 committed by GitHub
parent 80cae8e620
commit 6e81ceec91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 952 additions and 633 deletions

View file

@ -2,30 +2,7 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()</ID>
<ID>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, )</ID>
<ID>LongParameterList:AndroidNodeListViewModel.kt$AndroidNodeListViewModel$( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, radioController: RadioController, nodeManagementActions: NodeManagementActions, getFilteredNodesUseCase: GetFilteredNodesUseCase, nodeFilterPreferences: NodeFilterPreferences, )</ID>
<ID>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, )</ID>
<ID>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, )</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1000L</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-5</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-7</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$0xff</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$3</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$4</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$8</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$1000</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception</ID>
<ID>TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception</ID>
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
<ID>TooManyFunctions:NordicBleInterface.kt$NordicBleInterface : RadioTransport</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Uri>(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,
)
}
}

View file

@ -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<NavKey>.channelsGraph(backStack: NavBackStack<NavKey>) {
entry<ChannelsRoutes.ChannelsGraph> {
ChannelScreen(
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
onNavigate = { route -> backStack.add(route) },
onNavigateUp = { backStack.removeLastOrNull() },
)
@ -36,7 +36,7 @@ fun EntryProviderScope<NavKey>.channelsGraph(backStack: NavBackStack<NavKey>) {
entry<ChannelsRoutes.Channels> {
ChannelScreen(
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
onNavigate = { route -> backStack.add(route) },
onNavigateUp = { backStack.removeLastOrNull() },
)

View file

@ -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<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
entry<ConnectionsRoutes.ConnectionsGraph> {
ConnectionsScreen(
scanModel = koinViewModel<AndroidScannerViewModel>(),
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
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<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>)
entry<ConnectionsRoutes.Connections> {
ConnectionsScreen(
scanModel = koinViewModel<AndroidScannerViewModel>(),
radioConfigViewModel = koinViewModel<AndroidRadioConfigViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },

View file

@ -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

View file

@ -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<NavKey>.nodeDetailGraph(
}
entry<NodeDetailRoutes.TracerouteLog> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>()
val metricsViewModel = koinViewModel<MetricsViewModel>()
metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen(
@ -135,7 +134,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
}
entry<NodeDetailRoutes.TracerouteMap> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>()
val metricsViewModel = koinViewModel<MetricsViewModel>()
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
@ -177,7 +176,7 @@ private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailS
crossinline getDestNum: (R) -> Int,
) {
entry<R> { args ->
val metricsViewModel = koinViewModel<AndroidMetricsViewModel>()
val metricsViewModel = koinViewModel<MetricsViewModel>()
val destNum = getDestNum(args)
metricsViewModel.setNodeId(destNum)

View file

@ -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<NavKey>): AndroidRadioConfigViewModel {
val viewModel = koinViewModel<AndroidRadioConfigViewModel>()
internal fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
val viewModel = koinViewModel<RadioConfigViewModel>()
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<NavKey>): AndroidRa
fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.SettingsGraph> {
SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(),
settingsViewModel = koinViewModel<SettingsViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
@ -101,7 +101,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.Settings> {
SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(),
settingsViewModel = koinViewModel<SettingsViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
@ -118,7 +118,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
entry<SettingsRoutes.ModuleConfiguration> {
val settingsViewModel: AndroidSettingsViewModel = koinViewModel()
val settingsViewModel: SettingsViewModel = koinViewModel()
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = getRadioConfigViewModel(backStack),
@ -189,7 +189,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
entry<SettingsRoutes.DebugPanel> {
val viewModel: AndroidDebugViewModel = koinViewModel()
val viewModel: DebugViewModel = koinViewModel()
DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
@ -209,14 +209,14 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
fun <R : Route> EntryProviderScope<NavKey>.configComposable(
route: KClass<R>,
backStack: NavBackStack<NavKey>,
content: @Composable (AndroidRadioConfigViewModel) -> Unit,
content: @Composable (RadioConfigViewModel) -> Unit,
) {
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
}
inline fun <reified R : Route> EntryProviderScope<NavKey>.configComposable(
backStack: NavBackStack<NavKey>,
noinline content: @Composable (AndroidRadioConfigViewModel) -> Unit,
noinline content: @Composable (RadioConfigViewModel) -> Unit,
) {
entry<R> { content(getRadioConfigViewModel(backStack)) }
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int>("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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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}" }
}
}
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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}" }
}
}
}
}

View file

@ -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)

View file

@ -66,6 +66,7 @@ internal fun Project.configureTestOptions() {
tasks.withType<Test>().configureEach {
// Parallelize unit tests
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
maxHeapSize = "2g"
// Show test results in the console
testLogging {

View file

@ -0,0 +1,5 @@
# Track deep_dive_docs_20260316 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View file

@ -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."
}

View file

@ -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

View file

@ -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).

View file

@ -0,0 +1,5 @@
# Track extract_viewmodels_20260316 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View file

@ -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."
}

View file

@ -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

View file

@ -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.

View file

@ -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.
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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())

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
}

View file

@ -29,6 +29,7 @@ kotlin {
android {
namespace = "org.meshtastic.core.network"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
sourceSets {

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import io.mockk.confirmVerified
import io.mockk.mockk

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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?
}

View file

@ -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"))

View file

@ -1,5 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues/>
<CurrentIssues>
<ID>TooGenericExceptionCaught:AndroidFileService.kt$AndroidFileService$e: Exception</ID>
<ID>TooGenericExceptionCaught:JvmFileService.kt$JvmFileService$e: Exception</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Application>(relaxed = true)
val service = AndroidFileService(mockContext)
assertNotNull(service)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Application>(relaxed = true)
val mockRepo = mockk<LocationRepository>(relaxed = true)
val service = AndroidLocationService(mockContext, mockRepo)
assertNotNull(service)
}
}

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.messaging.domain.worker
package org.meshtastic.core.service
import android.content.Context
import androidx.test.core.app.ApplicationProvider

View file

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.app.Application
import android.content.Context

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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:

View file

@ -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<MeshtasticUri>(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<Int> = 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<SharedContact?> = 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<TracerouteResponse?>

View file

@ -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`

View file

@ -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

View file

@ -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<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
onHandleScannedUri: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,

View file

@ -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) }
}
},

View file

@ -2,10 +2,10 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:CompassViewModel.kt$CompassViewModel$@Suppress("ReturnCount") private fun calculatePositionalAccuracyMeters(): Float?</ID>
<ID>CyclomaticComplexMethod:NodeDetailActions.kt$NodeDetailActions$fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction)</ID>
<ID>CyclomaticComplexMethod:NodeDetailViewModel.kt$NodeDetailViewModel$fun handleNodeMenuAction(action: NodeMenuAction)</ID>
<ID>MagicNumber:CompassViewModel.kt$CompassViewModel$180.0</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7</ID>
<ID>MaxLineLength:MetricsViewModel.kt$MetricsViewModel$"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n"</ID>
<ID>TooGenericExceptionCaught:MetricsViewModel.kt$MetricsViewModel$e: Exception</ID>
<ID>TooGenericExceptionCaught:NodeManagementActions.kt$NodeManagementActions$ex: Exception</ID>
</CurrentIssues>

View file

@ -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()) }
}
}

View file

@ -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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<suspend (okio.BufferedSink) -> 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()
}
}

View file

@ -2,16 +2,10 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>CyclomaticComplexMethod:ExternalNotificationConfigItemList.kt$@Suppress("LongMethod", "TooGenericExceptionCaught") @Composable fun ExternalNotificationConfigScreen( onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: RadioConfigViewModel, )</ID>
<ID>CyclomaticComplexMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>CyclomaticComplexMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:DeviceConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
@ -22,6 +16,7 @@
<ID>LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:UserConfigItemList.kt$@Composable fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>MagicNumber:Debug.kt$3</ID>
<ID>MagicNumber:DebugViewModel.kt$DebugViewModel$16</ID>
<ID>MagicNumber:DebugViewModel.kt$DebugViewModel$8</ID>
<ID>MagicNumber:EditChannelDialog.kt$16</ID>
<ID>MagicNumber:EditChannelDialog.kt$32</ID>
@ -29,9 +24,9 @@
<ID>MagicNumber:EditDeviceProfileDialog.kt$ProfileField.CONFIG$4</ID>
<ID>MagicNumber:EditDeviceProfileDialog.kt$ProfileField.FIXED_POSITION$6</ID>
<ID>MagicNumber:EditDeviceProfileDialog.kt$ProfileField.MODULE_CONFIG$5</ID>
<ID>MagicNumber:PacketResponseStateDialog.kt$100</ID>
<ID>ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>
<ID>TooGenericExceptionCaught:DebugViewModel.kt$DebugViewModel$e: Exception</ID>
<ID>TooGenericExceptionCaught:RadioConfigViewModel.kt$RadioConfigViewModel$ex: Exception</ID>
<ID>TooManyFunctions:RadioConfigViewModel.kt$RadioConfigViewModel : ViewModel</ID>
<ID>UnusedPrivateProperty:RadioConfigViewModel.kt$RadioConfigViewModel$private val locationRepository: LocationRepository</ID>
</CurrentIssues>

View file

@ -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(

View file

@ -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))

View file

@ -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) }
}
}

View file

@ -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<MyNodeInfo?> = 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)
}

View file

@ -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

View file

@ -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) {

View file

@ -80,6 +80,7 @@ class SettingsViewModelTest {
meshLocationUseCase = mockk(relaxed = true),
exportDataUseCase = mockk(relaxed = true),
isOtaCapableUseCase = mockk(relaxed = true),
fileService = mockk(relaxed = true),
)
}

View file

@ -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