mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(mqtt): adopt mqttastic-client-kmp 0.2.0 — disconnect reasons + Test Connection (#5181)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
5c870028d4
commit
84e70d01a3
12 changed files with 425 additions and 55 deletions
|
|
@ -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<MqttConnectionState> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
|
@ -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>(ConnectionState.Disconnected.Idle)
|
||||
override val connectionState: StateFlow<ConnectionState> = _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"
|
||||
|
|
|
|||
|
|
@ -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<MqttEndpoint.WebSocket>(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<MqttEndpoint.WebSocket>(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<MqttEndpoint.WebSocket>(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<MqttEndpoint.WebSocket>(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<MqttEndpoint.WebSocket>(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<MqttEndpoint.Tcp>(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<MqttEndpoint.Tcp>(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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -636,9 +636,21 @@
|
|||
<string name="mqtt_config">MQTT Config</string>
|
||||
<string name="mqtt_status_inactive">Inactive</string>
|
||||
<string name="mqtt_status_disconnected">Disconnected</string>
|
||||
<string name="mqtt_status_disconnected_with_reason">Disconnected — %1$s</string>
|
||||
<string name="mqtt_status_connecting">Connecting…</string>
|
||||
<string name="mqtt_status_connected">Connected</string>
|
||||
<string name="mqtt_status_reconnecting">Reconnecting…</string>
|
||||
<string name="mqtt_status_reconnecting_with_attempt">Reconnecting (attempt %1$d) — %2$s</string>
|
||||
<string name="mqtt_test_connection">Test connection</string>
|
||||
<string name="mqtt_probe_running">Probing broker…</string>
|
||||
<string name="mqtt_probe_success">Reachable. Broker accepted credentials.</string>
|
||||
<string name="mqtt_probe_success_with_info">Reachable (%1$s)</string>
|
||||
<string name="mqtt_probe_rejected">Broker rejected: %1$s</string>
|
||||
<string name="mqtt_probe_dns_failure">Host not found</string>
|
||||
<string name="mqtt_probe_tcp_failure">Cannot reach broker (TCP)</string>
|
||||
<string name="mqtt_probe_tls_failure">TLS handshake failed</string>
|
||||
<string name="mqtt_probe_timeout">Timed out after %1$d ms</string>
|
||||
<string name="mqtt_probe_other_failure">Connection failed</string>
|
||||
<string name="mqtt_enabled">MQTT enabled</string>
|
||||
<string name="address">Address</string>
|
||||
<string name="username">Username</string>
|
||||
|
|
|
|||
|
|
@ -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>(MqttConnectionState.Disconnected.Idle)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
|
|||
|
|
@ -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<MqttConnectionState> = mqttManager.mqttConnectionState
|
||||
|
||||
private val _mqttProbeStatus = MutableStateFlow<MqttProbeStatus?>(null)
|
||||
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* 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<Int>("destNum"))
|
||||
|
||||
fun initDestNum(id: Int?) {
|
||||
|
|
|
|||
|
|
@ -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<ModuleConfig.MQTTConfig>,
|
||||
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<String, Color>? = 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue