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