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:
James Rich 2026-04-17 21:33:55 -05:00 committed by GitHub
parent 5c870028d4
commit 84e70d01a3
12 changed files with 425 additions and 55 deletions

View file

@ -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?) {

View file

@ -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
}

View file

@ -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)