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 androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.settings.AndroidDebugViewModel import org.meshtastic.app.settings.AndroidDebugViewModel
import org.meshtastic.app.settings.AndroidRadioConfigViewModel import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.app.settings.AndroidSettingsViewModel import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.navigation.SettingsRoutes
@ -74,8 +74,8 @@ import kotlin.reflect.KClass
@PublishedApi @PublishedApi
@Composable @Composable
internal fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): AndroidRadioConfigViewModel { internal fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
val viewModel = koinViewModel<AndroidRadioConfigViewModel>() val viewModel = koinViewModel<RadioConfigViewModel>()
LaunchedEffect(backStack) { LaunchedEffect(backStack) {
val destNum = val destNum =
backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).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>) { fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.SettingsGraph> { entry<SettingsRoutes.SettingsGraph> {
SettingsScreen( SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(), settingsViewModel = koinViewModel<SettingsViewModel>(),
viewModel = getRadioConfigViewModel(backStack), viewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) { ) {
@ -101,7 +101,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.Settings> { entry<SettingsRoutes.Settings> {
SettingsScreen( SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(), settingsViewModel = koinViewModel<SettingsViewModel>(),
viewModel = getRadioConfigViewModel(backStack), viewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) }, onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) { ) {
@ -118,7 +118,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
} }
entry<SettingsRoutes.ModuleConfiguration> { entry<SettingsRoutes.ModuleConfiguration> {
val settingsViewModel: AndroidSettingsViewModel = koinViewModel() val settingsViewModel: SettingsViewModel = koinViewModel()
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen( ModuleConfigurationScreen(
viewModel = getRadioConfigViewModel(backStack), viewModel = getRadioConfigViewModel(backStack),
@ -209,14 +209,14 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
fun <R : Route> EntryProviderScope<NavKey>.configComposable( fun <R : Route> EntryProviderScope<NavKey>.configComposable(
route: KClass<R>, route: KClass<R>,
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
content: @Composable (AndroidRadioConfigViewModel) -> Unit, content: @Composable (RadioConfigViewModel) -> Unit,
) { ) {
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) } addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
} }
inline fun <reified R : Route> EntryProviderScope<NavKey>.configComposable( inline fun <reified R : Route> EntryProviderScope<NavKey>.configComposable(
backStack: NavBackStack<NavKey>, backStack: NavBackStack<NavKey>,
noinline content: @Composable (AndroidRadioConfigViewModel) -> Unit, noinline content: @Composable (RadioConfigViewModel) -> Unit,
) { ) {
entry<R> { content(getRadioConfigViewModel(backStack)) } 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 package org.meshtastic.feature.settings
import org.meshtastic.core.common.util.toMeshtasticUri
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -97,14 +99,14 @@ fun SettingsScreen(
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) { if (it.resultCode == Activity.RESULT_OK) {
showEditDeviceProfileDialog = true 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 = val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) { 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, cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) }, onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
nodeShortName = ourNode?.user?.short_name ?: "", nodeShortName = ourNode?.user?.short_name ?: "",
onExportData = { settingsViewModel.saveDataCsv(it) }, onExportData = { settingsViewModel.saveDataCsv(it.toMeshtasticUri()) },
) )
AppInfoSection( AppInfoSection(

View file

@ -16,7 +16,9 @@
*/ */
package org.meshtastic.feature.settings.radio.component package org.meshtastic.feature.settings.radio.component
import org.meshtastic.core.common.util.toMeshtasticUri
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -94,7 +96,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val exportConfigLauncher = val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) { 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.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed 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 import org.meshtastic.proto.LocalConfig
@KoinViewModel @KoinViewModel
@Suppress("LongParameterList", "TooManyFunctions") @Suppress("LongParameterList", "TooManyFunctions")
open class SettingsViewModel( class SettingsViewModel(
radioConfigRepository: RadioConfigRepository, radioConfigRepository: RadioConfigRepository,
private val radioController: RadioController, private val radioController: RadioController,
private val nodeRepository: NodeRepository, private val nodeRepository: NodeRepository,
@ -68,6 +70,7 @@ open class SettingsViewModel(
private val meshLocationUseCase: MeshLocationUseCase, private val meshLocationUseCase: MeshLocationUseCase,
private val exportDataUseCase: ExportDataUseCase, private val exportDataUseCase: ExportDataUseCase,
private val isOtaCapableUseCase: IsOtaCapableUseCase, private val isOtaCapableUseCase: IsOtaCapableUseCase,
private val fileService: FileService,
) : ViewModel() { ) : ViewModel() {
val myNodeInfo: StateFlow<MyNodeInfo?> = nodeRepository.myNodeInfo val myNodeInfo: StateFlow<MyNodeInfo?> = nodeRepository.myNodeInfo
@ -161,11 +164,13 @@ open class SettingsViewModel(
* @param uri The destination URI for the CSV file. * @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported. * @param filterPortnum If provided, only packets with this port number will be exported.
*/ */
open fun saveDataCsv(uri: Any, filterPortnum: Int? = null) { fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) {
// To be implemented in platform-specific subclass 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 val myNodeNum = myNodeNum ?: return
exportDataUseCase(writer, myNodeNum, filterPortnum) exportDataUseCase(writer, myNodeNum, filterPortnum)
} }

View file

@ -73,6 +73,10 @@ import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User 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 that represents the current RadioConfig state. */
data class RadioConfigState( data class RadioConfigState(
val isLocal: Boolean = false, val isLocal: Boolean = false,
@ -113,6 +117,8 @@ open class RadioConfigViewModel(
private val radioConfigUseCase: RadioConfigUseCase, private val radioConfigUseCase: RadioConfigUseCase,
private val adminActionsUseCase: AdminActionsUseCase, private val adminActionsUseCase: AdminActionsUseCase,
private val processRadioResponseUseCase: ProcessRadioResponseUseCase, private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
private val locationService: LocationService,
private val fileService: FileService,
) : ViewModel() { ) : ViewModel() {
var analyticsAllowedFlow = analyticsPrefs.analyticsAllowed var analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
@ -150,7 +156,7 @@ open class RadioConfigViewModel(
val currentDeviceProfile val currentDeviceProfile
get() = _currentDeviceProfile.value get() = _currentDeviceProfile.value
open suspend fun getCurrentLocation(): Any? = null suspend fun getCurrentLocation(): Any? = locationService.getCurrentLocation()
init { init {
combine(destNumFlow, nodeRepository.nodeDBbyNum) { id, nodes -> nodes[id] ?: nodes.values.firstOrNull() } combine(destNumFlow, nodeRepository.nodeDBbyNum) { id, nodes -> nodes[id] ?: nodes.values.firstOrNull() }
@ -363,16 +369,44 @@ open class RadioConfigViewModel(
viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) } viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
} }
open fun importProfile(uri: Any, onResult: (DeviceProfile) -> Unit) { fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) {
// To be implemented in platform-specific subclass 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) { fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) {
// To be implemented in platform-specific subclass 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) { fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) {
// To be implemented in platform-specific subclass 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) { fun installProfile(protobuf: DeviceProfile) {

View file

@ -80,6 +80,7 @@ class SettingsViewModelTest {
meshLocationUseCase = mockk(relaxed = true), meshLocationUseCase = mockk(relaxed = true),
exportDataUseCase = mockk(relaxed = true), exportDataUseCase = mockk(relaxed = true),
isOtaCapableUseCase = 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 radioConfigUseCase: RadioConfigUseCase = mockk(relaxed = true)
private val adminActionsUseCase: AdminActionsUseCase = mockk(relaxed = true) private val adminActionsUseCase: AdminActionsUseCase = mockk(relaxed = true)
private val processRadioResponseUseCase: ProcessRadioResponseUseCase = 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 private lateinit var viewModel: RadioConfigViewModel
@ -110,7 +112,6 @@ class RadioConfigViewModelTest {
private fun createViewModel() = RadioConfigViewModel( private fun createViewModel() = RadioConfigViewModel(
savedStateHandle = SavedStateHandle(), savedStateHandle = SavedStateHandle(),
app = mockk(),
radioConfigRepository = radioConfigRepository, radioConfigRepository = radioConfigRepository,
packetRepository = packetRepository, packetRepository = packetRepository,
serviceRepository = serviceRepository, serviceRepository = serviceRepository,
@ -128,6 +129,8 @@ class RadioConfigViewModelTest {
radioConfigUseCase = radioConfigUseCase, radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase, adminActionsUseCase = adminActionsUseCase,
processRadioResponseUseCase = processRadioResponseUseCase, processRadioResponseUseCase = processRadioResponseUseCase,
locationService = locationService,
fileService = fileService,
) )
@Test @Test