diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt index 9940db706..5693d343b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt @@ -31,12 +31,17 @@ import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.network.repository.MQTTRepository +import org.meshtastic.core.network.repository.resolveEndpoint import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.mqtt.ConnectionState +import org.meshtastic.mqtt.MqttClient import org.meshtastic.mqtt.MqttException +import org.meshtastic.mqtt.ProbeResult +import org.meshtastic.mqtt.probe import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.proto.ToRadio @@ -52,9 +57,9 @@ class MqttManagerImpl( override val mqttConnectionState: StateFlow = combine(proxyActive, mqttRepository.connectionState) { active, libState -> - if (!active) MqttConnectionState.INACTIVE else libState.toAppState() + if (!active) MqttConnectionState.Inactive else libState.toAppState() } - .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.INACTIVE) + .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.Inactive) override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) { if (mqttMessageFlow?.isActive == true) return @@ -102,9 +107,55 @@ class MqttManagerImpl( } private fun ConnectionState.toAppState(): MqttConnectionState = when (this) { - ConnectionState.DISCONNECTED -> MqttConnectionState.DISCONNECTED - ConnectionState.CONNECTING -> MqttConnectionState.CONNECTING - ConnectionState.CONNECTED -> MqttConnectionState.CONNECTED - ConnectionState.RECONNECTING -> MqttConnectionState.RECONNECTING + is ConnectionState.Connecting -> MqttConnectionState.Connecting + is ConnectionState.Connected -> MqttConnectionState.Connected + is ConnectionState.Reconnecting -> + MqttConnectionState.Reconnecting(attempt = attempt, lastError = lastError?.message) + is ConnectionState.Disconnected -> + reason?.let { MqttConnectionState.Disconnected(reason = it.message) } + ?: MqttConnectionState.Disconnected.Idle + } + + override suspend fun probe( + address: String, + tlsEnabled: Boolean, + username: String?, + password: String?, + ): MqttProbeStatus { + val endpoint = resolveEndpoint(address, tlsEnabled) + val result = + MqttClient.probe(endpoint = endpoint) { + val user = username?.takeUnless { it.isEmpty() } + val pass = password?.takeUnless { it.isEmpty() } + if (user != null) this.username = user + if (pass != null) password(pass) + } + return result.toAppStatus() + } + + private fun ProbeResult.toAppStatus(): MqttProbeStatus = when (this) { + is ProbeResult.Success -> { + val info = serverInfo + val summary = + buildList { + info.assignedClientIdentifier?.let { add("client=$it") } + info.maximumQosOrdinal?.let { add("maxQoS=$it") } + info.serverKeepAliveSeconds?.let { add("keepalive=${it}s") } + } + .joinToString(", ") + .ifEmpty { null } + MqttProbeStatus.Success(serverInfo = summary) + } + is ProbeResult.Rejected -> + MqttProbeStatus.Rejected( + reasonCode = reasonCode.value, + reason = message, + serverReference = serverReference, + ) + is ProbeResult.DnsFailure -> MqttProbeStatus.DnsFailure(message = cause.message) + is ProbeResult.TcpFailure -> MqttProbeStatus.TcpFailure(message = cause.message) + is ProbeResult.TlsFailure -> MqttProbeStatus.TlsFailure(message = cause.message) + is ProbeResult.Timeout -> MqttProbeStatus.Timeout(timeoutMs = durationMs) + is ProbeResult.Other -> MqttProbeStatus.Other(message = cause.message) } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt index 6a5b9ad15..4d3bfca10 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt @@ -16,20 +16,41 @@ */ package org.meshtastic.core.model -/** App-level MQTT proxy connection state, decoupled from the MQTT library's internal type. */ -enum class MqttConnectionState { +/** + * App-level MQTT proxy connection state, decoupled from the MQTT library's internal type. + * + * Modeled as a sealed class so disconnect / reconnect events can carry diagnostic context — the user-facing reason for + * an unexpected disconnect, or the most recent reconnect attempt failure — without requiring downstream consumers to + * depend on the MQTT library's exception types. + */ +sealed class MqttConnectionState { /** The MQTT proxy has not been started (disabled or not yet initialized). */ - INACTIVE, - - /** The MQTT client is not connected to the broker. */ - DISCONNECTED, + data object Inactive : MqttConnectionState() /** The MQTT client is actively connecting to the broker. */ - CONNECTING, + data object Connecting : MqttConnectionState() /** The MQTT client is connected and subscribed to topics. */ - CONNECTED, + data object Connected : MqttConnectionState() - /** The MQTT client lost connection and is attempting to reconnect. */ - RECONNECTING, + /** + * The MQTT client lost connection and is attempting to reconnect. + * + * @property attempt 1-based attempt counter for the current reconnect loop. + * @property lastError Localized message from the most recent reconnect failure, if any. + */ + data class Reconnecting(val attempt: Int = 0, val lastError: String? = null) : MqttConnectionState() + + /** + * The MQTT client is not connected to the broker. + * + * @property reason Localized failure message for an unexpected disconnect, or `null` for the idle / initial / + * intentional-close case (use [Idle]). + */ + data class Disconnected(val reason: String? = null) : MqttConnectionState() { + companion object { + /** Singleton for the idle / no-reason disconnected state. */ + val Idle: Disconnected = Disconnected(reason = null) + } + } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt new file mode 100644 index 000000000..e3cb7c77a --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt @@ -0,0 +1,52 @@ +/* + * 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.core.model + +/** + * UI-friendly outcome of a one-shot MQTT broker reachability probe. + * + * Mirrors the failure shapes of `org.meshtastic.mqtt.ProbeResult` but stays in the model module so feature/UI code can + * consume the result without depending on the MQTT library. + */ +sealed class MqttProbeStatus { + /** Probe is currently in flight. */ + data object Probing : MqttProbeStatus() + + /** + * Broker accepted the connection. [serverInfo] is a short human-readable summary of any CONNACK properties that are + * useful to surface to the user. + */ + data class Success(val serverInfo: String?) : MqttProbeStatus() + + /** Broker rejected the connection (CONNACK with non-zero reason code). */ + data class Rejected(val reasonCode: Int, val reason: String?, val serverReference: String?) : MqttProbeStatus() + + /** DNS lookup failed. */ + data class DnsFailure(val message: String?) : MqttProbeStatus() + + /** TCP socket could not be opened. */ + data class TcpFailure(val message: String?) : MqttProbeStatus() + + /** TLS handshake failed. */ + data class TlsFailure(val message: String?) : MqttProbeStatus() + + /** Probe exceeded its timeout. */ + data class Timeout(val timeoutMs: Long) : MqttProbeStatus() + + /** Any other / unclassified failure. */ + data class Other(val message: String?) : MqttProbeStatus() +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index 94ab7f0ce..47cfb6f7a 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -65,7 +65,6 @@ class MQTTRepositoryImpl( private const val DEFAULT_TOPIC_LEVEL = "/2/e/" private const val JSON_TOPIC_LEVEL = "/2/json/" private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org" - private const val WEBSOCKET_PATH = "/mqtt" private const val KEEPALIVE_SECONDS = 30 private const val INITIAL_RECONNECT_DELAY_MS = 1000L private const val MAX_RECONNECT_DELAY_MS = 30_000L @@ -74,7 +73,7 @@ class MQTTRepositoryImpl( @Volatile private var client: MqttClient? = null - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected.Idle) override val connectionState: StateFlow = _connectionState.asStateFlow() @OptIn(ExperimentalSerializationApi::class) @@ -89,7 +88,7 @@ class MQTTRepositoryImpl( Logger.i { "MQTT Disconnecting" } val c = client client = null - _connectionState.value = ConnectionState.DISCONNECTED + _connectionState.value = ConnectionState.Disconnected.Idle scope.launch { safeCatching { c?.close() }.onFailure { e -> Logger.w(e) { "MQTT clean disconnect failed" } } } } @@ -102,14 +101,7 @@ class MQTTRepositoryImpl( val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT } ?: DEFAULT_TOPIC_ROOT val rawAddress = mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS - val endpoint = - if (rawAddress.contains("://")) { - MqttEndpoint.parse(rawAddress) - } else { - // Use WebSocket transport on all platforms for firewall/CDN compatibility. - val scheme = if (mqttConfig?.tls_enabled == true) "wss" else "ws" - MqttEndpoint.parse("$scheme://$rawAddress$WEBSOCKET_PATH") - } + val endpoint = resolveEndpoint(rawAddress, mqttConfig?.tls_enabled == true) val newClient = MqttClient(ownerId) { @@ -226,3 +218,26 @@ class MQTTRepositoryImpl( } } } + +/** + * Resolve a user-supplied broker address into an [MqttEndpoint]. + * + * Address resolution rules: + * - If [rawAddress] already contains a URI scheme (`scheme://…`), parse it directly via [MqttEndpoint.parse] and + * respect whatever transport / port the user encoded. + * - Otherwise wrap it as a WebSocket endpoint (`ws[s]://host${WEBSOCKET_PATH}`) so the proxy works over CDNs and + * firewall-restricted networks where raw 1883/8883 may be blocked. The scheme is `wss` when [tlsEnabled] is `true`, + * `ws` otherwise. + * + * Extracted as a top-level function so [MQTTRepositoryImplTest] can exercise every branch without spinning up the full + * repository, and so `MqttManagerImpl` (in `:core:data`) can reuse the same parsing rules for the probe API. Visibility + * is `public` because Kotlin's `internal` is scoped per Gradle module. + */ +fun resolveEndpoint(rawAddress: String, tlsEnabled: Boolean): MqttEndpoint = if (rawAddress.contains("://")) { + MqttEndpoint.parse(rawAddress) +} else { + val scheme = if (tlsEnabled) "wss" else "ws" + MqttEndpoint.parse("$scheme://$rawAddress$WEBSOCKET_PATH") +} + +private const val WEBSOCKET_PATH = "/mqtt" diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt index 73e096da9..26b83a420 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt @@ -18,25 +18,82 @@ package org.meshtastic.core.network.repository import kotlinx.serialization.json.Json import org.meshtastic.core.model.MqttJsonPayload +import org.meshtastic.mqtt.MqttEndpoint import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertTrue class MQTTRepositoryImplTest { - @Test - fun `test address parsing logic`() { - val address1 = "mqtt.example.com:1883" - val (host1, port1) = address1.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) } - assertEquals("mqtt.example.com", host1) - assertEquals(1883, port1) + // region resolveEndpoint — every behavioral branch of address parsing. - val address2 = "mqtt.example.com" - val (host2, port2) = address2.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) } - assertEquals("mqtt.example.com", host2) - assertEquals(1883, port2) + @Test + fun `bare host without scheme is wrapped as ws WebSocket on the standard port`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com/mqtt", ws.url) } + @Test + fun `bare host with TLS enabled is upgraded to wss`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = true) + + val ws = assertIs(endpoint) + assertEquals("wss://broker.example.com/mqtt", ws.url) + } + + @Test + fun `host with explicit port is preserved when wrapped`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com:9001", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com:9001/mqtt", ws.url) + } + + @Test + fun `address with ws scheme is parsed as-is and tls flag is ignored`() { + // tlsEnabled is intentionally true here — when the user supplies a full URL we + // must honor whatever scheme they provided, not silently upgrade it. + val endpoint = resolveEndpoint(rawAddress = "ws://broker.example.com:8080/custom-path", tlsEnabled = true) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com:8080/custom-path", ws.url) + } + + @Test + fun `address with wss scheme is parsed as-is`() { + val endpoint = resolveEndpoint(rawAddress = "wss://broker.example.com/secure-mqtt", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("wss://broker.example.com/secure-mqtt", ws.url) + } + + @Test + fun `address with mqtt tcp scheme is parsed as Tcp endpoint`() { + val endpoint = resolveEndpoint(rawAddress = "mqtt://broker.example.com:1883", tlsEnabled = false) + + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(1883, tcp.port) + assertEquals(false, tcp.tls) + } + + @Test + fun `address with mqtts tcp scheme is parsed as Tcp endpoint with tls true`() { + val endpoint = resolveEndpoint(rawAddress = "mqtts://broker.example.com:8883", tlsEnabled = false) + + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(8883, tcp.port) + assertEquals(true, tcp.tls) + } + + // endregion + + // region MqttJsonPayload — keep the existing JSON contract tests. + @Test fun `test json payload parsing`() { val jsonStr = @@ -72,4 +129,6 @@ class MQTTRepositoryImplTest { assertTrue(jsonStr.contains("\"from\":12345678")) assertTrue(jsonStr.contains("\"payload\":\"Hello World\"")) } + + // endregion } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt index d91ae7080..6701514f8 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.repository import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.proto.MqttClientProxyMessage /** Interface for managing MQTT proxy communication. */ @@ -33,4 +34,15 @@ interface MqttManager { /** Handles an MQTT proxy message from the radio. */ fun handleMqttProxyMessage(message: MqttClientProxyMessage) + + /** + * Probe an MQTT broker to verify connectivity and credentials without joining the proxy lifecycle. Intended for UI + * "Test Connection" affordances. + * + * @param address Raw broker address as the user would type it (host, host:port, or full URL). + * @param tlsEnabled `true` to upgrade bare addresses to `wss://` (ignored when [address] already has a scheme). + * @param username Optional MQTT username. + * @param password Optional MQTT password. + */ + suspend fun probe(address: String, tlsEnabled: Boolean, username: String?, password: String?): MqttProbeStatus } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 481a94b78..505d80821 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -636,9 +636,21 @@ MQTT Config Inactive Disconnected + Disconnected — %1$s Connecting… Connected Reconnecting… + Reconnecting (attempt %1$d) — %2$s + Test connection + Probing broker… + Reachable. Broker accepted credentials. + Reachable (%1$s) + Broker rejected: %1$s + Host not found + Cannot reach broker (TCP) + TLS handshake failed + Timed out after %1$d ms + Connection failed MQTT enabled Address Username diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index f366d821b..707dfaf03 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -164,7 +164,7 @@ class NoopMQTTRepository : MQTTRepository { override fun publish(topic: String, data: ByteArray, retained: Boolean) {} - override val connectionState = MutableStateFlow(MqttConnectionState.DISCONNECTED) + override val connectionState = MutableStateFlow(MqttConnectionState.Disconnected.Idle) } // endregion 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 e443a3f75..c59f00b56 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 @@ -20,14 +20,17 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow 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 import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel @@ -44,6 +47,7 @@ import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position @@ -144,6 +148,38 @@ open class RadioConfigViewModel( /** MQTT proxy connection state for the settings UI. */ val mqttConnectionState: StateFlow = mqttManager.mqttConnectionState + private val _mqttProbeStatus = MutableStateFlow(null) + + /** Latest result from a [probeMqttConnection] call, or `null` if no probe has been run. */ + val mqttProbeStatus: StateFlow = _mqttProbeStatus.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. 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 = + runCatching { mqttManager.probe(address, tlsEnabled, username, password) } + .getOrElse { e -> + Logger.w(e) { "MQTT probe threw" } + MqttProbeStatus.Other(message = e.message) + } + _mqttProbeStatus.value = result + } + } + + /** Clear the latest probe result (e.g. when the user edits the address). */ + fun clearMqttProbeStatus() { + probeJob?.cancel() + _mqttProbeStatus.value = null + } + private val destNumFlow = MutableStateFlow(savedStateHandle.get("destNum")) fun initDestNum(id: Int?) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt index 972a9d43f..e1f407679 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt @@ -21,12 +21,15 @@ package org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -36,6 +39,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction @@ -44,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.address import org.meshtastic.core.resources.default_mqtt_address @@ -53,11 +58,23 @@ import org.meshtastic.core.resources.map_reporting import org.meshtastic.core.resources.mqtt import org.meshtastic.core.resources.mqtt_config import org.meshtastic.core.resources.mqtt_enabled +import org.meshtastic.core.resources.mqtt_probe_dns_failure +import org.meshtastic.core.resources.mqtt_probe_other_failure +import org.meshtastic.core.resources.mqtt_probe_rejected +import org.meshtastic.core.resources.mqtt_probe_running +import org.meshtastic.core.resources.mqtt_probe_success +import org.meshtastic.core.resources.mqtt_probe_success_with_info +import org.meshtastic.core.resources.mqtt_probe_tcp_failure +import org.meshtastic.core.resources.mqtt_probe_timeout +import org.meshtastic.core.resources.mqtt_probe_tls_failure import org.meshtastic.core.resources.mqtt_status_connected import org.meshtastic.core.resources.mqtt_status_connecting import org.meshtastic.core.resources.mqtt_status_disconnected +import org.meshtastic.core.resources.mqtt_status_disconnected_with_reason import org.meshtastic.core.resources.mqtt_status_inactive import org.meshtastic.core.resources.mqtt_status_reconnecting +import org.meshtastic.core.resources.mqtt_status_reconnecting_with_attempt +import org.meshtastic.core.resources.mqtt_test_connection import org.meshtastic.core.resources.password import org.meshtastic.core.resources.proxy_to_client_enabled import org.meshtastic.core.resources.root_topic @@ -75,6 +92,7 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val destNode by viewModel.destNode.collectAsStateWithLifecycle() val mqttProxyState by viewModel.mqttConnectionState.collectAsStateWithLifecycle() + val probeStatus by viewModel.mqttProbeStatus.collectAsStateWithLifecycle() val destNum = destNode?.num val mqttConfig = state.moduleConfig.mqtt ?: ModuleConfig.MQTTConfig() val formState = rememberConfigState(initialValue = mqttConfig) @@ -119,16 +137,13 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.address), - value = formState.value.address, - maxSize = 63, // address max_size:64 + MqttAddressAndProbe( enabled = state.connected, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(address = it) }, + formState = formState, + probeStatus = probeStatus, + focusManager = focusManager, + onProbe = viewModel::probeMqttConnection, + onClearProbe = viewModel::clearMqttProbeStatus, ) HorizontalDivider() EditTextPreference( @@ -241,13 +256,26 @@ private val GreenColor = Color(0xFF4CAF50) private fun MqttStatusRow(state: MqttConnectionState) { val (label, color) = when (state) { - MqttConnectionState.INACTIVE -> + is MqttConnectionState.Inactive -> stringResource(Res.string.mqtt_status_inactive) to MaterialTheme.colorScheme.outline - MqttConnectionState.DISCONNECTED -> - stringResource(Res.string.mqtt_status_disconnected) to MaterialTheme.colorScheme.error - MqttConnectionState.CONNECTING -> stringResource(Res.string.mqtt_status_connecting) to AmberColor - MqttConnectionState.CONNECTED -> stringResource(Res.string.mqtt_status_connected) to GreenColor - MqttConnectionState.RECONNECTING -> stringResource(Res.string.mqtt_status_reconnecting) to AmberColor + is MqttConnectionState.Disconnected -> { + val text = + state.reason?.let { stringResource(Res.string.mqtt_status_disconnected_with_reason, it) } + ?: stringResource(Res.string.mqtt_status_disconnected) + text to MaterialTheme.colorScheme.error + } + is MqttConnectionState.Connecting -> stringResource(Res.string.mqtt_status_connecting) to AmberColor + is MqttConnectionState.Connected -> stringResource(Res.string.mqtt_status_connected) to GreenColor + is MqttConnectionState.Reconnecting -> { + val err = state.lastError + val text = + if (err != null) { + stringResource(Res.string.mqtt_status_reconnecting_with_attempt, state.attempt, err) + } else { + stringResource(Res.string.mqtt_status_reconnecting) + } + text to AmberColor + } } Row( verticalAlignment = Alignment.CenterVertically, @@ -262,3 +290,87 @@ private fun MqttStatusRow(state: MqttConnectionState) { ) } } + +@Composable +private fun MqttAddressAndProbe( + enabled: Boolean, + formState: ConfigState, + probeStatus: MqttProbeStatus?, + focusManager: FocusManager, + onProbe: (address: String, tlsEnabled: Boolean, username: String, password: String) -> Unit, + onClearProbe: () -> Unit, +) { + EditTextPreference( + title = stringResource(Res.string.address), + value = formState.value.address, + maxSize = 63, // address max_size:64 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + formState.value = formState.value.copy(address = it) + onClearProbe() + }, + ) + HorizontalDivider() + MqttProbeRow( + enabled = enabled && formState.value.address.isNotBlank(), + status = probeStatus, + onTestClick = { + focusManager.clearFocus() + onProbe( + formState.value.address, + formState.value.tls_enabled, + formState.value.username, + formState.value.password, + ) + }, + ) +} + +@Composable +private fun MqttProbeRow(enabled: Boolean, status: MqttProbeStatus?, onTestClick: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Button(onClick = onTestClick, enabled = enabled && status !is MqttProbeStatus.Probing) { + Text(stringResource(Res.string.mqtt_test_connection)) + } + val (probeText, probeColor) = status.toLabel() ?: return@Row + Text(text = probeText, style = MaterialTheme.typography.bodySmall, color = probeColor) + } + } +} + +@Composable +private fun MqttProbeStatus?.toLabel(): Pair? = when (this) { + null -> null + is MqttProbeStatus.Probing -> + stringResource(Res.string.mqtt_probe_running) to MaterialTheme.colorScheme.onSurfaceVariant + is MqttProbeStatus.Success -> { + val text = + serverInfo?.let { stringResource(Res.string.mqtt_probe_success_with_info, it) } + ?: stringResource(Res.string.mqtt_probe_success) + text to GreenColor + } + is MqttProbeStatus.Rejected -> + stringResource(Res.string.mqtt_probe_rejected, reason ?: reasonCode.toString()) to + MaterialTheme.colorScheme.error + is MqttProbeStatus.DnsFailure -> + stringResource(Res.string.mqtt_probe_dns_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.TcpFailure -> + stringResource(Res.string.mqtt_probe_tcp_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.TlsFailure -> + stringResource(Res.string.mqtt_probe_tls_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.Timeout -> + stringResource(Res.string.mqtt_probe_timeout, timeoutMs.toInt()) to MaterialTheme.colorScheme.error + is MqttProbeStatus.Other -> + stringResource(Res.string.mqtt_probe_other_failure) to MaterialTheme.colorScheme.error +} 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 6e11f6b92..c1b7d8a9e 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 @@ -124,7 +124,7 @@ class RadioConfigViewModelTest { MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) every { mqttManager.mqttConnectionState } returns - MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.INACTIVE) + MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive) every { uiPrefs.showQuickChat } returns MutableStateFlow(false) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe96dc45e..91f2ea6b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,7 +74,7 @@ spotless = "8.4.0" wire = "6.2.0" vico = "3.1.0" kable = "0.42.0" -mqttastic = "0.1.0" +mqttastic = "0.2.0" jmdns = "3.6.3" qrcode-kotlin = "4.5.0"