refactor: extract MqttProbeCoordinator and ProfileCoordinator from RadioConfigViewModel

Extract self-contained logic into plain coordinator classes:
- MqttProbeCoordinator: MQTT broker probe state + cancellation
- ProfileCoordinator: import/export/install profile file I/O

RadioConfigViewModel delegates to coordinators while retaining
state ownership and config-loading orchestration (its core job).
Reduces VM body by ~60 lines of implementation detail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-06 13:23:41 -05:00
parent 07214bd307
commit 44b2a8c98a
4 changed files with 179 additions and 40 deletions

View File

@@ -0,0 +1,72 @@
/*
* 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.settings.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.MqttConnectionState
import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.repository.MqttManager
/**
* Encapsulates MQTT broker reachability probing logic.
* Injected into [RadioConfigViewModel] to keep probe state and cancellation self-contained.
*/
class MqttProbeCoordinator(
private val mqttManager: MqttManager,
private val scope: CoroutineScope,
) {
/** MQTT proxy connection state for the settings UI. */
val mqttConnectionState: StateFlow<MqttConnectionState> = mqttManager.mqttConnectionState
private val _probeStatus = MutableStateFlow<MqttProbeStatus?>(null)
/** Latest result from a [probe] call, or `null` if no probe has been run. */
val probeStatus: StateFlow<MqttProbeStatus?> = _probeStatus.asStateFlow()
private var probeJob: Job? = null
/**
* Run a one-shot reachability/credentials probe against an MQTT broker.
* Cancels any in-flight probe before starting a new one.
*/
fun probe(address: String, tlsEnabled: Boolean, username: String?, password: String?) {
probeJob?.cancel()
_probeStatus.value = MqttProbeStatus.Probing
probeJob = scope.launch {
val result =
safeCatching { mqttManager.probe(address, tlsEnabled, username, password) }
.getOrElse { e ->
Logger.w(e) { "MQTT probe threw" }
MqttProbeStatus.Other(message = e.message)
}
_probeStatus.value = result
}
}
/** Clear the latest probe result (e.g. when the user edits the address). */
fun clearProbeStatus() {
probeJob?.cancel()
_probeStatus.value = null
}
}

View File

@@ -0,0 +1,91 @@
/*
* 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.settings.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.CommonUri
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.repository.FileService
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.User
/**
* Encapsulates device-profile import/export/install operations.
* Injected into [RadioConfigViewModel] to keep file I/O logic self-contained.
*/
class ProfileCoordinator(
private val fileService: FileService,
private val importProfileUseCase: ImportProfileUseCase,
private val exportProfileUseCase: ExportProfileUseCase,
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
private val installProfileUseCase: InstallProfileUseCase,
private val scope: CoroutineScope,
) {
fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) {
scope.launch {
try {
var profile: DeviceProfile? = null
fileService.read(uri) { source ->
importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it }
}
profile?.let { onResult(it) }
} catch (e: Exception) {
Logger.e(e) { "[importProfile] failed" }
}
}
}
fun exportProfile(uri: CommonUri, profile: DeviceProfile) {
scope.launch {
try {
fileService.write(uri) { sink ->
exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it }
}
} catch (e: Exception) {
Logger.e(e) { "[exportProfile] failed" }
}
}
}
fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) {
scope.launch {
try {
fileService.write(uri) { sink ->
exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it }
}
} catch (e: Exception) {
Logger.e(e) { "[exportSecurityConfig] failed" }
}
}
}
fun installProfile(destNum: Int, protobuf: DeviceProfile, user: User?) {
scope.launch {
try {
installProfileUseCase(destNum, protobuf, user)
} catch (e: Exception) {
Logger.e(e) { "[installProfile] failed" }
}
}
}
}

View File

@@ -24,7 +24,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
@@ -37,7 +36,6 @@ import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
@@ -142,39 +140,25 @@ open class RadioConfigViewModel(
homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value)
}
/** MQTT proxy connection state for the settings UI. */
val mqttConnectionState: StateFlow<MqttConnectionState> = mqttManager.mqttConnectionState
private val mqttProbeCoordinator = MqttProbeCoordinator(mqttManager, viewModelScope)
private val _mqttProbeStatus = MutableStateFlow<MqttProbeStatus?>(null)
/** MQTT proxy connection state for the settings UI. */
val mqttConnectionState: StateFlow<MqttConnectionState> = mqttProbeCoordinator.mqttConnectionState
/** Latest result from a [probeMqttConnection] call, or `null` if no probe has been run. */
val mqttProbeStatus: StateFlow<MqttProbeStatus?> = _mqttProbeStatus.asStateFlow()
private var probeJob: Job? = null
val mqttProbeStatus: StateFlow<MqttProbeStatus?> = mqttProbeCoordinator.probeStatus
/**
* Run a one-shot reachability/credentials probe against an MQTT broker. Cancels any in-flight probe before starting
* a new one. Result is exposed via [mqttProbeStatus].
*/
fun probeMqttConnection(address: String, tlsEnabled: Boolean, username: String?, password: String?) {
probeJob?.cancel()
_mqttProbeStatus.value = MqttProbeStatus.Probing
probeJob =
viewModelScope.launch {
val result =
safeCatching { mqttManager.probe(address, tlsEnabled, username, password) }
.getOrElse { e ->
Logger.w(e) { "MQTT probe threw" }
MqttProbeStatus.Other(message = e.message)
}
_mqttProbeStatus.value = result
}
mqttProbeCoordinator.probe(address, tlsEnabled, username, password)
}
/** Clear the latest probe result (e.g. when the user edits the address). */
fun clearMqttProbeStatus() {
probeJob?.cancel()
_mqttProbeStatus.value = null
mqttProbeCoordinator.clearProbeStatus()
}
private val destNumFlow = MutableStateFlow(savedStateHandle.get<Int>("destNum"))
@@ -372,35 +356,26 @@ open class RadioConfigViewModel(
writeAction("removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) }
}
private val profileCoordinator = ProfileCoordinator(
fileService, importProfileUseCase, exportProfileUseCase,
exportSecurityConfigUseCase, installProfileUseCase, viewModelScope,
)
fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) {
safeLaunch(tag = "importProfile") {
var profile: DeviceProfile? = null
fileService.read(uri) { source ->
importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it }
}
profile?.let { onResult(it) }
}
profileCoordinator.importProfile(uri, onResult)
}
fun exportProfile(uri: CommonUri, profile: DeviceProfile) {
safeLaunch(tag = "exportProfile") {
fileService.write(uri) { sink ->
exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it }
}
}
profileCoordinator.exportProfile(uri, profile)
}
fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) {
safeLaunch(tag = "exportSecurityConfig") {
fileService.write(uri) { sink ->
exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it }
}
}
profileCoordinator.exportSecurityConfig(uri, securityConfig)
}
fun installProfile(protobuf: DeviceProfile) {
val destNum = destNode.value?.num ?: return
safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
profileCoordinator.installProfile(destNum, protobuf, destNode.value?.user)
}
// region RadioConfigStateProvider implementation

View File

@@ -43,6 +43,7 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.model.AdminException
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.ResponseState
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.HomoglyphPrefs