From 44b2a8c98aab9f3931849135ab06334fbbf0bbde Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 13:23:41 -0500 Subject: [PATCH] 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> --- .../settings/radio/MqttProbeCoordinator.kt | 72 +++++++++++++++ .../settings/radio/ProfileCoordinator.kt | 91 +++++++++++++++++++ .../settings/radio/RadioConfigViewModel.kt | 55 +++-------- .../radio/RadioConfigViewModelTest.kt | 1 + 4 files changed, 179 insertions(+), 40 deletions(-) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/MqttProbeCoordinator.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ProfileCoordinator.kt diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/MqttProbeCoordinator.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/MqttProbeCoordinator.kt new file mode 100644 index 000000000..92aad19ed --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/MqttProbeCoordinator.kt @@ -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 . + */ +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 = mqttManager.mqttConnectionState + + private val _probeStatus = MutableStateFlow(null) + + /** Latest result from a [probe] call, or `null` if no probe has been run. */ + val probeStatus: StateFlow = _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 + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ProfileCoordinator.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ProfileCoordinator.kt new file mode 100644 index 000000000..0ff97aaaf --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ProfileCoordinator.kt @@ -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 . + */ +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" } + } + } + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index d2a06d693..348a895a9 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -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 = mqttManager.mqttConnectionState + private val mqttProbeCoordinator = MqttProbeCoordinator(mqttManager, viewModelScope) - private val _mqttProbeStatus = MutableStateFlow(null) + /** MQTT proxy connection state for the settings UI. */ + val mqttConnectionState: StateFlow = mqttProbeCoordinator.mqttConnectionState /** Latest result from a [probeMqttConnection] call, or `null` if no probe has been run. */ - val mqttProbeStatus: StateFlow = _mqttProbeStatus.asStateFlow() - - private var probeJob: Job? = null + val mqttProbeStatus: StateFlow = 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("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 diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 213fda4f9..89c42a587 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -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