refactor(feature/settings): Extract Settings and RadioConfig ViewModels to commonMain

This commit is contained in:
James Rich 2026-03-16 10:53:02 -05:00
parent 3c872b2d01
commit 091452a559
9 changed files with 72 additions and 296 deletions

View file

@ -27,8 +27,8 @@ 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.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@ -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),
@ -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,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

@ -16,6 +16,8 @@
*/
package org.meshtastic.feature.settings
import org.meshtastic.core.common.util.toMeshtasticUri
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
@ -97,14 +99,14 @@ 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 +236,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

@ -16,7 +16,9 @@
*/
package org.meshtastic.feature.settings.radio.component
import org.meshtastic.core.common.util.toMeshtasticUri
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -94,7 +96,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

@ -47,11 +47,13 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.common.util.MeshtasticUri
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,13 @@ 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

@ -73,6 +73,10 @@ import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.LocationService
import org.meshtastic.core.common.util.MeshtasticUri
/** Data class that represents the current RadioConfig state. */
data class RadioConfigState(
val isLocal: Boolean = false,
@ -113,6 +117,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 +156,7 @@ open class RadioConfigViewModel(
val currentDeviceProfile
get() = _currentDeviceProfile.value
open suspend fun getCurrentLocation(): Any? = null
suspend fun getCurrentLocation(): Any? = locationService.getCurrentLocation()
init {
combine(destNumFlow, nodeRepository.nodeDBbyNum) { id, nodes -> nodes[id] ?: nodes.values.firstOrNull() }
@ -363,16 +369,44 @@ 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 {
fileService.read(uri) { source ->
importProfileUseCase(source).onSuccess(onResult).onFailure { throw 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