From a97f70430016ecde955146b0a890520c95ba5cf0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:19:08 -0500 Subject: [PATCH 01/27] feat(mqtt): migrate to MQTTastic-Client-KMP (#5165) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/manager/MqttManagerImpl.kt | 36 ++- .../core/model/MqttConnectionState.kt | 35 +++ core/network/build.gradle.kts | 3 +- .../core/network/repository/MQTTRepository.kt | 5 + .../network/repository/MQTTRepositoryImpl.kt | 241 +++++++++--------- .../meshtastic/core/repository/MqttManager.kt | 5 + .../composeResources/values/strings.xml | 5 + .../org/meshtastic/desktop/stub/NoopStubs.kt | 3 + .../settings/radio/RadioConfigViewModel.kt | 6 + .../radio/component/MQTTConfigItemList.kt | 52 ++++ .../radio/RadioConfigViewModelTest.kt | 6 + gradle/libs.versions.toml | 5 +- 12 files changed, 271 insertions(+), 131 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt 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 b928e8505..9940db706 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 @@ -20,15 +20,23 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +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.network.repository.MQTTRepository 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.MqttException import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.proto.ToRadio @@ -40,18 +48,30 @@ class MqttManagerImpl( @Named("ServiceScope") private val scope: CoroutineScope, ) : MqttManager { private var mqttMessageFlow: Job? = null + private val proxyActive = MutableStateFlow(false) + + override val mqttConnectionState: StateFlow = + combine(proxyActive, mqttRepository.connectionState) { active, libState -> + if (!active) MqttConnectionState.INACTIVE else libState.toAppState() + } + .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.INACTIVE) override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) { if (mqttMessageFlow?.isActive == true) return if (enabled && proxyToClientEnabled) { + proxyActive.value = true mqttMessageFlow = mqttRepository.proxyMessageFlow .onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) } .catch { throwable -> - serviceRepository.setErrorMessage( - text = "MqttClientProxy failed: $throwable", - severity = Severity.Warn, - ) + proxyActive.value = false + val message = + when (throwable) { + is MqttException.ConnectionRejected -> "MQTT: connection rejected (check credentials)" + is MqttException.ConnectionLost -> "MQTT: connection lost" + else -> "MQTT proxy failed: ${throwable.message}" + } + serviceRepository.setErrorMessage(text = message, severity = Severity.Warn) } .launchIn(scope) } @@ -63,6 +83,7 @@ class MqttManagerImpl( mqttMessageFlow?.cancel() mqttMessageFlow = null } + proxyActive.value = false } override fun handleMqttProxyMessage(message: MqttClientProxyMessage) { @@ -79,4 +100,11 @@ class MqttManagerImpl( else -> {} } } + + private fun ConnectionState.toAppState(): MqttConnectionState = when (this) { + ConnectionState.DISCONNECTED -> MqttConnectionState.DISCONNECTED + ConnectionState.CONNECTING -> MqttConnectionState.CONNECTING + ConnectionState.CONNECTED -> MqttConnectionState.CONNECTED + ConnectionState.RECONNECTING -> MqttConnectionState.RECONNECTING + } } 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 new file mode 100644 index 000000000..6a5b9ad15 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025-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 + +/** App-level MQTT proxy connection state, decoupled from the MQTT library's internal type. */ +enum 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, + + /** The MQTT client is actively connecting to the broker. */ + CONNECTING, + + /** The MQTT client is connected and subscribed to topics. */ + CONNECTED, + + /** The MQTT client lost connection and is attempting to reconnect. */ + RECONNECTING, +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index c3dc2ffd5..f2fb85d7f 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -40,8 +40,7 @@ kotlin { implementation(projects.core.ble) implementation(libs.okio) - implementation(libs.kmqtt.client) - implementation(libs.kmqtt.common) + api(libs.meshtastic.mqtt.client) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt index fe092fd7c..9efb9150b 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt @@ -17,6 +17,8 @@ package org.meshtastic.core.network.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.mqtt.ConnectionState import org.meshtastic.proto.MqttClientProxyMessage /** Interface defining the MQTT interactions used for proxying messages to and from the mesh. */ @@ -38,4 +40,7 @@ interface MQTTRepository { * @param retained Whether the message should be retained by the broker. */ fun publish(topic: String, data: ByteArray, retained: Boolean) + + /** Observable MQTT connection lifecycle state (DISCONNECTED → CONNECTING → CONNECTED → RECONNECTING). */ + val connectionState: StateFlow } 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 5e4ffa91d..94ab7f0ce 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 @@ -17,22 +17,15 @@ package org.meshtastic.core.network.repository import co.touchlab.kermit.Logger -import io.github.davidepianca98.MQTTClient -import io.github.davidepianca98.mqtt.MQTTException -import io.github.davidepianca98.mqtt.MQTTVersion -import io.github.davidepianca98.mqtt.Subscription -import io.github.davidepianca98.mqtt.packets.Qos -import io.github.davidepianca98.mqtt.packets.mqttv5.ReasonCode -import io.github.davidepianca98.mqtt.packets.mqttv5.SubscriptionOptions -import io.github.davidepianca98.socket.IOException -import io.github.davidepianca98.socket.tls.TLSClientSettings -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.ProducerScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -44,11 +37,19 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecodingException import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MqttJsonPayload import org.meshtastic.core.model.util.subscribeList import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.mqtt.ConnectionState +import org.meshtastic.mqtt.MqttClient +import org.meshtastic.mqtt.MqttEndpoint +import org.meshtastic.mqtt.MqttException +import org.meshtastic.mqtt.MqttMessage +import org.meshtastic.mqtt.QoS +import org.meshtastic.mqtt.packet.Subscription import org.meshtastic.proto.MqttClientProxyMessage import kotlin.concurrent.Volatile @@ -64,12 +65,17 @@ 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 private const val RECONNECT_BACKOFF_MULTIPLIER = 2 } - @Volatile private var client: MQTTClient? = null + @Volatile private var client: MqttClient? = null + + private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + override val connectionState: StateFlow = _connectionState.asStateFlow() @OptIn(ExperimentalSerializationApi::class) private val json = Json { @@ -77,25 +83,17 @@ class MQTTRepositoryImpl( exceptionsWithDebugInfo = false } private val scope = CoroutineScope(dispatchers.default + SupervisorJob()) - - @Volatile private var clientJob: Job? = null private val publishSemaphore = Semaphore(20) - @Suppress("TooGenericExceptionCaught") override fun disconnect() { Logger.i { "MQTT Disconnecting" } val c = client - client = null // Null first to prevent re-entrant disconnect - try { - c?.disconnect(ReasonCode.SUCCESS) - } catch (e: Exception) { - Logger.w(e) { "MQTT clean disconnect failed" } - } - clientJob?.cancel() - clientJob = null + client = null + _connectionState.value = ConnectionState.DISCONNECTED + scope.launch { safeCatching { c?.close() }.onFailure { e -> Logger.w(e) { "MQTT clean disconnect failed" } } } } - @OptIn(ExperimentalUnsignedTypes::class) + @OptIn(ExperimentalSerializationApi::class) override val proxyMessageFlow: Flow = callbackFlow { val ownerId = "MeshtasticAndroidMqttProxy-${nodeRepository.myId.value ?: "unknown"}" val channelSet = radioConfigRepository.channelSetFlow.first() @@ -103,108 +101,112 @@ class MQTTRepositoryImpl( val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT } ?: DEFAULT_TOPIC_ROOT - val (host, port) = - (mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let { - it[0] to (it.getOrNull(1)?.toIntOrNull() ?: if (mqttConfig?.tls_enabled == true) 8883 else 1883) + 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 newClient = - MQTTClient( - mqttVersion = MQTTVersion.MQTT5, - address = host, - port = port, - tls = if (mqttConfig?.tls_enabled == true) TLSClientSettings() else null, - userName = mqttConfig?.username, - password = mqttConfig?.password?.encodeToByteArray()?.toUByteArray(), - clientId = ownerId, - publishReceived = { packet -> - val topic = packet.topicName - val payload = packet.payload?.toByteArray() - Logger.d { "MQTT received message on topic $topic (size: ${payload?.size ?: 0} bytes)" } - - if (topic.contains("/json/")) { - try { - val jsonStr = payload?.decodeToString() ?: "" - // Validate JSON by parsing it - json.decodeFromString(jsonStr) - Logger.d { "MQTT parsed JSON payload successfully" } - - trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = packet.retain)) - } catch (e: JsonDecodingException) { - @OptIn(ExperimentalSerializationApi::class) - Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" } - } catch (e: SerializationException) { - Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } - } catch (e: IllegalArgumentException) { - Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } - } - } else { - trySend( - MqttClientProxyMessage( - topic = topic, - data_ = payload?.toByteString() ?: okio.ByteString.EMPTY, - retained = packet.retain, - ), - ) - } - }, - ) - + MqttClient(ownerId) { + keepAliveSeconds = KEEPALIVE_SECONDS + autoReconnect = true + username = mqttConfig?.username + mqttConfig?.password?.let { password(it) } + } client = newClient - // Subscribe before starting the event loop. KMQTT's subscribe() calls send(), - // which queues the SUBSCRIBE packet in pendingSendMessages while connackReceived - // is false. Once the event loop receives CONNACK, it flushes the queue — so - // subscriptions are guaranteed to be sent immediately after the connection is - // established, with no timing races. This replaces a previous yield()-based - // approach that was unreliable on lightly loaded dispatchers. - val subscriptions = mutableListOf() - channelSet.subscribeList.forEach { globalId -> - subscriptions.add( - Subscription("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), - ) - if (mqttConfig?.json_enabled == true) { - subscriptions.add( - Subscription("$rootTopic$JSON_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), + val subscriptions: List = buildList { + channelSet.subscribeList.forEach { globalId -> + add( + Subscription( + "$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", + maxQos = QoS.AT_LEAST_ONCE, + noLocal = true, + ), ) - } - } - subscriptions.add(Subscription("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+", SubscriptionOptions(Qos.AT_LEAST_ONCE))) - - if (subscriptions.isNotEmpty()) { - Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } - newClient.subscribe(subscriptions) - } - - clientJob = - scope.launch { - var reconnectDelay = INITIAL_RECONNECT_DELAY_MS - while (true) { - try { - Logger.i { "MQTT Starting client loop for $host:$port" } - newClient.runSuspend() - // runSuspend returned normally — broker closed connection cleanly. - // Reset backoff so the next reconnect starts with the minimum delay. - reconnectDelay = INITIAL_RECONNECT_DELAY_MS - Logger.w { "MQTT client loop ended normally, reconnecting in ${reconnectDelay}ms" } - } catch (e: MQTTException) { - Logger.e(e) { "MQTT Client loop error (MQTT), reconnecting in ${reconnectDelay}ms" } - } catch (e: IOException) { - Logger.e(e) { "MQTT Client loop error (IO), reconnecting in ${reconnectDelay}ms" } - } catch (e: CancellationException) { - Logger.i { "MQTT Client loop cancelled" } - throw e - } - delay(reconnectDelay) - reconnectDelay = - (reconnectDelay * RECONNECT_BACKOFF_MULTIPLIER).coerceAtMost(MAX_RECONNECT_DELAY_MS) + if (mqttConfig?.json_enabled == true) { + add( + Subscription( + "$rootTopic$JSON_TOPIC_LEVEL$globalId/+", + maxQos = QoS.AT_LEAST_ONCE, + noLocal = true, + ), + ) } } + add(Subscription("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+", maxQos = QoS.AT_LEAST_ONCE, noLocal = true)) + } + + // Collect from the SharedFlow before connecting to avoid missing retained messages + // that arrive immediately after SUBSCRIBE. + launch { newClient.messages.collect { msg -> processMessage(msg) } } + + // Forward the client's connection state to the repo-level StateFlow for UI observation. + launch { newClient.connectionState.collect { _connectionState.value = it } } + + // Retry the initial connect with exponential backoff. Once established, + // autoReconnect handles subsequent drops and re-subscribes internally. + launch { + var reconnectDelay = INITIAL_RECONNECT_DELAY_MS + while (true) { + val result = safeCatching { + Logger.i { "MQTT Connecting to $endpoint" } + newClient.connect(endpoint) + if (subscriptions.isNotEmpty()) { + Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } + newClient.subscribe(subscriptions) + } + Logger.i { "MQTT connected and subscribed" } + } + when { + result.isSuccess -> return@launch + result.exceptionOrNull() is MqttException.ConnectionRejected -> { + Logger.e(result.exceptionOrNull()) { "MQTT connection rejected (unrecoverable), stopping" } + close(result.exceptionOrNull()!!) + return@launch + } + else -> { + Logger.e(result.exceptionOrNull()) { "MQTT connect failed, retrying in ${reconnectDelay}ms" } + delay(reconnectDelay) + reconnectDelay = + (reconnectDelay * RECONNECT_BACKOFF_MULTIPLIER).coerceAtMost(MAX_RECONNECT_DELAY_MS) + } + } + } + } awaitClose { disconnect() } } - @OptIn(ExperimentalUnsignedTypes::class) + @OptIn(ExperimentalSerializationApi::class) + private fun ProducerScope.processMessage(msg: MqttMessage) { + val topic = msg.topic + val payload = msg.payload.toByteArray() + Logger.d { "MQTT received message on topic $topic (size: ${payload.size} bytes)" } + + if (topic.contains("/json/")) { + try { + val jsonStr = payload.decodeToString() + json.decodeFromString(jsonStr) + Logger.d { "MQTT parsed JSON payload successfully" } + trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = msg.retain)) + } catch (e: JsonDecodingException) { + Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" } + } catch (e: SerializationException) { + Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } + } catch (e: IllegalArgumentException) { + Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } + } + } else { + trySend(MqttClientProxyMessage(topic = topic, data_ = payload.toByteString(), retained = msg.retain)) + } + } + override fun publish(topic: String, data: ByteArray, retained: Boolean) { val currentClient = client if (currentClient == null) { @@ -214,17 +216,12 @@ class MQTTRepositoryImpl( Logger.d { "MQTT publishing message to topic $topic (size: ${data.size} bytes, retained: $retained)" } scope.launch { publishSemaphore.withPermit { - @Suppress("TooGenericExceptionCaught") - try { + safeCatching { currentClient.publish( - retain = retained, - qos = Qos.AT_LEAST_ONCE, - topic = topic, - payload = data.toUByteArray(), + MqttMessage(topic = topic, payload = data, qos = QoS.AT_LEAST_ONCE, retain = retained), ) - } catch (e: Exception) { - Logger.w(e) { "MQTT publish to $topic failed" } } + .onFailure { e -> Logger.w(e) { "MQTT publish to $topic failed" } } } } } 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 7ebfa0521..d91ae7080 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 @@ -16,10 +16,15 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.MqttConnectionState import org.meshtastic.proto.MqttClientProxyMessage /** Interface for managing MQTT proxy communication. */ interface MqttManager { + /** Observable MQTT proxy connection state for UI consumption. */ + val mqttConnectionState: StateFlow + /** Starts the MQTT proxy with the given settings. */ fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 4748844c6..9bd1b68de 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -638,6 +638,11 @@ Ignore MQTT Ok to MQTT MQTT Config + Inactive + Disconnected + Connecting… + Connected + Reconnecting… 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 985a76987..f366d821b 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.MqttClientProxyMessage +import org.meshtastic.mqtt.ConnectionState as MqttConnectionState import org.meshtastic.proto.Position as ProtoPosition /** @@ -162,6 +163,8 @@ class NoopMQTTRepository : MQTTRepository { override val proxyMessageFlow: Flow = emptyFlow() override fun publish(topic: String, data: ByteArray, retained: Boolean) {} + + override val connectionState = MutableStateFlow(MqttConnectionState.DISCONNECTED) } // 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 7a946b78b..e443a3f75 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 @@ -43,6 +43,7 @@ import org.meshtastic.core.domain.usecase.settings.RadioResponseResult 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.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position @@ -52,6 +53,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.MapConsentPrefs +import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository @@ -125,6 +127,7 @@ open class RadioConfigViewModel( private val processRadioResponseUseCase: ProcessRadioResponseUseCase, private val locationService: LocationService, private val fileService: FileService, + private val mqttManager: MqttManager, ) : ViewModel() { val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed @@ -138,6 +141,9 @@ open class RadioConfigViewModel( toggleHomoglyphEncodingUseCase() } + /** MQTT proxy connection state for the settings UI. */ + val mqttConnectionState: StateFlow = mqttManager.mqttConnectionState + 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 0427f9520..972a9d43f 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 @@ -18,17 +18,32 @@ 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.Row +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.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +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.resources.Res import org.meshtastic.core.resources.address import org.meshtastic.core.resources.default_mqtt_address @@ -38,6 +53,11 @@ 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_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_inactive +import org.meshtastic.core.resources.mqtt_status_reconnecting import org.meshtastic.core.resources.password import org.meshtastic.core.resources.proxy_to_client_enabled import org.meshtastic.core.resources.root_topic @@ -54,6 +74,7 @@ import org.meshtastic.proto.ModuleConfig 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 destNum = destNode?.num val mqttConfig = state.moduleConfig.mqtt ?: ModuleConfig.MQTTConfig() val formState = rememberConfigState(initialValue = mqttConfig) @@ -86,6 +107,8 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { viewModel.setModuleConfig(config) }, ) { + item { MqttStatusRow(mqttProxyState) } + item { TitledCard(title = stringResource(Res.string.mqtt_config)) { SwitchPreference( @@ -210,3 +233,32 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } private const val MIN_INTERVAL_SECS = 3600 + +private val AmberColor = Color(0xFFFFA000) +private val GreenColor = Color(0xFF4CAF50) + +@Composable +private fun MqttStatusRow(state: MqttConnectionState) { + val (label, color) = + when (state) { + 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 + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(color)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} 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 167daebbf..6e11f6b92 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 @@ -53,6 +53,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.MapConsentPrefs +import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository @@ -99,6 +100,7 @@ class RadioConfigViewModelTest { private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mock(MockMode.autofill) private val locationService: LocationService = mock(MockMode.autofill) private val fileService: FileService = mock(MockMode.autofill) + private val mqttManager: MqttManager = mock(MockMode.autofill) private val uiPrefs: UiPrefs = mock(MockMode.autofill) private lateinit var viewModel: RadioConfigViewModel @@ -121,6 +123,9 @@ class RadioConfigViewModelTest { every { serviceRepository.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) + every { mqttManager.mqttConnectionState } returns + MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.INACTIVE) + every { uiPrefs.showQuickChat } returns MutableStateFlow(false) viewModel = createViewModel() @@ -152,6 +157,7 @@ class RadioConfigViewModelTest { processRadioResponseUseCase = processRadioResponseUseCase, locationService = locationService, fileService = fileService, + mqttManager = mqttManager, ) @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 668ed133a..12ab9480c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,7 @@ spotless = "8.4.0" wire = "6.2.0" vico = "3.1.0" kable = "0.42.0" -kmqtt = "1.0.0" +mqttastic = "0.1.0" jmdns = "3.6.3" qrcode-kotlin = "4.5.0" @@ -220,8 +220,7 @@ markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-rend material = { module = "com.google.android.material:material", version = "1.13.0" } kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" } -kmqtt-client = { module = "io.github.davidepianca98:kmqtt-client", version.ref = "kmqtt" } -kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref = "kmqtt" } +meshtastic-mqtt-client = { module = "org.meshtastic:mqtt-client", version.ref = "mqttastic" } jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } From adfe3bfed1891edec0cfd7649931e48d8d2e505a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:18:45 -0500 Subject: [PATCH 02/27] refactor: use injected ioDispatcher and ApplicationCoroutineScope (#5167) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/di/ApplicationCoroutineScope.kt | 39 +++++++++++++++++++ .../meshtastic/core/ui/util/PlatformUtils.kt | 4 +- .../meshtastic/core/ui/util/PlatformUtils.kt | 4 +- .../firmware/FirmwareUpdateViewModel.kt | 12 +++--- .../firmware/FirmwareUpdateIntegrationTest.kt | 1 + .../firmware/FirmwareUpdateViewModelTest.kt | 1 + .../firmware/TestApplicationCoroutineScope.kt | 26 +++++++++++++ .../FirmwareUpdateViewModelFileTest.kt | 1 + .../feature/settings/debugging/LogExporter.kt | 3 +- .../feature/settings/tak/PrefExporter.kt | 4 +- .../feature/settings/debugging/LogExporter.kt | 4 +- .../feature/settings/tak/PrefExporter.kt | 4 +- 12 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt create mode 100644 feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt new file mode 100644 index 000000000..2a27b9690 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt @@ -0,0 +1,39 @@ +/* + * 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.common.di + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.ioDispatcher + +/** + * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. + * + * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled + * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not + * cancel siblings, and by [ioDispatcher] so work runs off the main thread. + * + * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch + * and should be used sparingly. + */ +interface ApplicationCoroutineScope : CoroutineScope + +@Single(binds = [ApplicationCoroutineScope::class]) +internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { + override val coroutineContext = SupervisorJob() + ioDispatcher +} diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 231c84d40..5365ab95e 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect import co.touchlab.kermit.Logger import com.eygraber.uri.toAndroidUri import com.eygraber.uri.toKmpUri -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.ioDispatcher import java.net.URLEncoder @Composable @@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> val context = LocalContext.current return remember(context) { { uri, maxChars -> - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { @Suppress("TooGenericExceptionCaught") try { val androidUri = uri.toAndroidUri() diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 031e1fe35..a938f92ea 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.ioDispatcher import java.awt.Desktop import java.awt.FileDialog import java.awt.Frame @@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT /** JVM — Reads text from a file URI. */ @Composable actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { @Suppress("TooGenericExceptionCaught") try { val file = File(URI(uri.toString())) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index dc1c45971..f8ff9fcac 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.di.ApplicationCoroutineScope import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.database.entity.FirmwareRelease @@ -91,6 +92,7 @@ class FirmwareUpdateViewModel( private val firmwareUpdateManager: FirmwareUpdateManager, private val usbManager: FirmwareUsbManager, private val fileHandler: FirmwareFileHandler, + private val applicationScope: ApplicationCoroutineScope, ) : ViewModel() { private val _state = MutableStateFlow(FirmwareUpdateState.Idle) @@ -124,12 +126,10 @@ class FirmwareUpdateViewModel( override fun onCleared() { super.onCleared() - // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a - // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a - // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope - // is cancelled concurrently. - @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) - kotlinx.coroutines.GlobalScope.launch(NonCancellable) { + // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the + // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup + // running even if something tries to cancel it mid-flight. + applicationScope.launch(NonCancellable) { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index 4c48a1ced..030d84eff 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) @Test diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index 7032ed408..a8eddff83 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) @Test diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt new file mode 100644 index 000000000..3ef5c44ef --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt @@ -0,0 +1,26 @@ +/* + * 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.firmware + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.meshtastic.core.common.di.ApplicationCoroutineScope + +internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : + ApplicationCoroutineScope, + CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index acb1545bd..23a0d03ab 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) // ----------------------------------------------------------------------- diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index c251b4d5e..315ad1da8 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_export_failed import org.meshtastic.core.resources.debug_export_success @@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { try { if (logs.isEmpty()) { withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt index 9afde85e5..a28a57678 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt @@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher @Composable actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { @@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr return { fileName -> exportLauncher.launch(fileName) } } -private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { +private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { try { context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } Logger.i { "TAK data package exported successfully to $targetUri" } diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 5b63cc90a..a9a728559 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr if (directory != null && file != null) { val targetFile = File(directory, file) val data = dataPackageProvider() - withContext(Dispatchers.IO) { targetFile.writeBytes(data) } + withContext(ioDispatcher) { targetFile.writeBytes(data) } Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } } } From cdeb1ac532b587f5db718504b5d6093ab55a859c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:20:50 -0500 Subject: [PATCH 03/27] fix: redact MeshLog proto secrets and centralize Compose keep-rules (#5166) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/proguard-rules.pro | 15 +++---------- config/proguard/shared-rules.pro | 21 +++++++++++++++++++ .../data/manager/MeshMessageProcessorImpl.kt | 12 ++++++----- .../meshtastic/core/model/util/Extensions.kt | 21 +++++++++++++++++++ 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 14df5580d..de2b3144c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -40,15 +40,6 @@ -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** -# ---- Compose Runtime & Animation -------------------------------------------- - -# Defence-in-depth: prevent R8 tree-shaking of Compose infrastructure classes -# that are referenced indirectly through compiler-generated state machines. -# With -dontoptimize above these are largely redundant, but they provide a -# safety net against future toolchain changes. --keep class androidx.compose.runtime.** { *; } --keep class androidx.compose.ui.** { *; } --keep class androidx.compose.animation.core.** { *; } --keep class androidx.compose.animation.** { *; } --keep class androidx.compose.foundation.** { *; } --keep class androidx.compose.material3.** { *; } +# Compose runtime/ui/animation/foundation/material3 keep rules now live in +# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard) +# get the same defence-in-depth coverage against CMP 1.11 optimizer folding. diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro index 902636dbf..fada20be3 100644 --- a/config/proguard/shared-rules.pro +++ b/config/proguard/shared-rules.pro @@ -177,3 +177,24 @@ # Core model classes (used in serialization, Room, and Koin injection) -keep class org.meshtastic.core.model.** { *; } + +# ---- Compose Runtime & Animation -------------------------------------------- + +# Defence-in-depth: prevent tree-shaking of Compose infrastructure classes that +# are referenced indirectly through compiler-generated state machines. Applies +# to BOTH R8 (Android app) and ProGuard (desktop distribution). +# +# Why shared: CMP 1.11 ships consumer rules with -assumenosideeffects on +# Composer.() / ComposerImpl.() and -assumevalues on +# ComposeRuntimeFlags / ComposeStackTraceMode. If the optimizer runs (R8 full +# mode on Android, ProGuard with optimize.set(true) on desktop) these call +# sites can be rewritten even when the target classes are kept, causing the +# recomposer / frame-clock / animation state machines to silently freeze on +# the first frame. -dontoptimize (set per-host) is the primary defence; these +# keep rules are a safety net against future toolchain changes. See #5146. +-keep class androidx.compose.runtime.** { *; } +-keep class androidx.compose.ui.** { *; } +-keep class androidx.compose.animation.core.** { *; } +-keep class androidx.compose.animation.** { *; } +-keep class androidx.compose.foundation.** { *; } +-keep class androidx.compose.material3.** { *; } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index 7a6ec3320..d9d21ad8b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -32,6 +32,8 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.isLora +import org.meshtastic.core.model.util.toOneLineString +import org.meshtastic.core.model.util.toPIIString import org.meshtastic.core.repository.FromRadioPacketHandler import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.MeshMessageProcessor @@ -125,11 +127,11 @@ class MeshMessageProcessorImpl( proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString() proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString() proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString() - proto.my_info != null -> "MyInfo" to proto.my_info.toString() - proto.node_info != null -> "NodeInfo" to proto.node_info.toString() - proto.config != null -> "Config" to proto.config.toString() - proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString() - proto.channel != null -> "Channel" to proto.channel.toString() + proto.my_info != null -> "MyInfo" to proto.my_info!!.toOneLineString() + proto.node_info != null -> "NodeInfo" to proto.node_info!!.toPIIString() + proto.config != null -> "Config" to proto.config!!.toOneLineString() + proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig!!.toOneLineString() + proto.channel != null -> "Channel" to proto.channel!!.toOneLineString() proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString() else -> return } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt index 47d812f68..dfe70fd92 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -18,8 +18,11 @@ package org.meshtastic.core.model.util +import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.Telemetry /** @@ -48,6 +51,24 @@ fun MeshPacket.toOneLineString(): String { return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') } +fun Channel.toOneLineString(): String { + // Redact the channel preshared key (psk) from logs. + val redactedFields = """(psk)=[^,}]+""" + return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') +} + +fun ModuleConfig.toOneLineString(): String { + // Redact MQTT credentials from logs. + val redactedFields = """(password|username)=[^,}]+""" + return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') +} + +fun MyNodeInfo.toOneLineString(): String { + // Redact the hardware unique identifier from logs. + val redactedFields = """(device_id)=[^,}]+""" + return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') +} + fun Any.toPIIString() = if (!isDebug) { "" } else { From 90f6e21a9c5529a25f4ee980bafec364f4bea45f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:24:18 -0500 Subject: [PATCH 04/27] fix(ui): stable LazyColumn keys, semantic roles, and content descriptions (#5168) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../composeResources/values/strings.xml | 6 +++ .../core/ui/component/ClickableTextField.kt | 3 +- .../core/ui/component/IndoorAirQuality.kt | 44 ++++++++++++++++--- .../core/ui/component/RegularPreference.kt | 9 +++- .../feature/messaging/component/Reaction.kt | 6 +-- .../node/component/NodeFilterTextField.kt | 15 +++++-- .../settings/debugging/DebugFilters.kt | 16 ++++++- .../radio/component/DeviceConfigScreen.kt | 11 ++++- .../radio/component/TAKConfigItemList.kt | 6 ++- .../wifiprovision/ui/WifiProvisionScreen.kt | 3 +- 10 files changed, 99 insertions(+), 20 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 9bd1b68de..87268ecda 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1270,4 +1270,10 @@ Show Meshtastic Quit Meshtastic + Export TAK Data Package + mPWRD-OS + Clear time zone + Filter + Remove filter + Show air quality legend diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt index 7330c1aa6..125e1e117 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt @@ -38,6 +38,7 @@ fun ClickableTextField( onClick: () -> Unit, modifier: Modifier = Modifier, isError: Boolean = false, + trailingIconContentDescription: String? = null, ) { val source = remember { MutableInteractionSource() } val isPressed by source.collectIsPressedAsState() @@ -49,7 +50,7 @@ fun ClickableTextField( enabled = enabled, readOnly = true, label = { Text(stringResource(label)) }, - trailingIcon = { Icon(trailingIcon, null) }, + trailingIcon = { Icon(trailingIcon, trailingIconContentDescription) }, isError = isError, interactionSource = source, modifier = modifier, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt index b84c11e13..2fa66b468 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -58,6 +59,7 @@ import org.meshtastic.core.resources.preview_gauge import org.meshtastic.core.resources.preview_gradient import org.meshtastic.core.resources.preview_pill import org.meshtastic.core.resources.preview_text +import org.meshtastic.core.resources.show_iaq_legend import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.ThumbUp import org.meshtastic.core.ui.icon.Warning @@ -120,13 +122,18 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil Column { when (displayMode) { IaqDisplayMode.Pill -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Box( modifier = Modifier.clip(RoundedCornerShape(10.dp)) .background(iaqEnum.color) .width(125.dp) .height(30.dp) - .clickable { isLegendOpen = true }, + .clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) { Row( modifier = Modifier.padding(4.dp).align(Alignment.CenterStart), @@ -144,7 +151,15 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Dot -> { - Column(modifier = Modifier.clickable { isLegendOpen = true }) { + val legendLabel = stringResource(Res.string.show_iaq_legend) + Column( + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), + ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(text = "$iaq") Spacer(modifier = Modifier.width(4.dp)) @@ -154,17 +169,30 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Text -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Text( text = getIaqDescriptionWithRange(iaqEnum), fontSize = 12.sp, - modifier = Modifier.clickable { isLegendOpen = true }, + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) } IaqDisplayMode.Gauge -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) CircularProgressIndicator( progress = { iaq / 500f }, - modifier = Modifier.size(60.dp).clickable { isLegendOpen = true }, + modifier = + Modifier.size(60.dp) + .clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), strokeWidth = 8.dp, color = iaqEnum.color, ) @@ -172,9 +200,15 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Gradient -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.clickable { isLegendOpen = true }, + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) { LinearProgressIndicator( progress = { iaq / 500f }, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt index afa82460d..f9f839ea5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -80,7 +81,13 @@ fun RegularPreference( MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) } - Column(modifier = modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(all = 16.dp)) { + Column( + modifier = + modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick, role = Role.Button) + .padding(all = 16.dp), + ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { FlowRow(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 27797592b..9b8267793 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -143,7 +143,7 @@ internal fun ReactionRow( AnimatedVisibility(emojiGroups.isNotEmpty(), modifier = modifier) { LazyRow(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - items(emojiGroups.entries.toList()) { entry -> + items(emojiGroups.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } @@ -237,7 +237,7 @@ internal fun ReactionDialog( } LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - items(groupedEmojis.entries.toList()) { entry -> + items(groupedEmojis.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } @@ -265,7 +265,7 @@ internal fun ReactionDialog( HorizontalDivider(Modifier.padding(vertical = 8.dp)) LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(filteredReactions) { reaction -> + items(filteredReactions, key = { reaction -> "${reaction.user.id}:${reaction.emoji}" }) { reaction -> Column(modifier = Modifier.padding(horizontal = 8.dp)) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt index cfac18158..0bc022c34 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign @@ -56,6 +57,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.clear import org.meshtastic.core.resources.desc_node_filter_clear import org.meshtastic.core.resources.node_filter_exclude_infrastructure import org.meshtastic.core.resources.node_filter_exclude_mqtt @@ -178,14 +180,19 @@ private fun NodeFilterTextField(filterText: String, onTextChange: (String) -> Un onValueChange = onTextChange, trailingIcon = { if (filterText.isNotEmpty() || isFocused) { + val clearLabel = stringResource(Res.string.clear) Icon( MeshtasticIcons.Close, contentDescription = stringResource(Res.string.desc_node_filter_clear), modifier = - Modifier.clickable { - onTextChange("") - focusManager.clearFocus() - }, + Modifier.clickable( + onClickLabel = clearLabel, + role = Role.Button, + onClick = { + onTextChange("") + focusManager.clearFocus() + }, + ), ) } }, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt index 37cdeab71..df4a0965f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt @@ -57,8 +57,10 @@ import org.meshtastic.core.resources.debug_filter_clear import org.meshtastic.core.resources.debug_filter_included import org.meshtastic.core.resources.debug_filter_preset_title import org.meshtastic.core.resources.debug_filters +import org.meshtastic.core.resources.filter_icon import org.meshtastic.core.resources.match_all import org.meshtastic.core.resources.match_any +import org.meshtastic.core.resources.remove_filter import org.meshtastic.core.ui.icon.Add import org.meshtastic.core.ui.icon.Check import org.meshtastic.core.ui.icon.Close @@ -281,8 +283,18 @@ fun DebugActiveFilters( selected = true, onClick = { onFilterTextsChange(filterTexts - filter) }, label = { Text(filter) }, - leadingIcon = { Icon(imageVector = MeshtasticIcons.FilterAlt, contentDescription = null) }, - trailingIcon = { Icon(imageVector = MeshtasticIcons.Close, contentDescription = null) }, + leadingIcon = { + Icon( + imageVector = MeshtasticIcons.FilterAlt, + contentDescription = stringResource(Res.string.filter_icon), + ) + }, + trailingIcon = { + Icon( + imageVector = MeshtasticIcons.Close, + contentDescription = stringResource(Res.string.remove_filter), + ) + }, ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt index c65cd971b..a614c1f99 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt @@ -59,6 +59,7 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.button_gpio import org.meshtastic.core.resources.buzzer_gpio import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.clear_time_zone import org.meshtastic.core.resources.config_device_doubleTapAsButtonPress_summary import org.meshtastic.core.resources.config_device_ledHeartbeatEnabled_summary import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summary @@ -269,7 +270,10 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit onValueChanged = { formState.value = formState.value.copy(tzdef = it) }, trailingIcon = { IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) { - Icon(imageVector = MeshtasticIcons.Close, contentDescription = null) + Icon( + imageVector = MeshtasticIcons.Close, + contentDescription = stringResource(Res.string.clear_time_zone), + ) } }, ) @@ -282,7 +286,10 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit shape = RectangleShape, onClick = { formState.value = formState.value.copy(tzdef = appTzPosixString) }, ) { - Icon(imageVector = MeshtasticIcons.PhoneAndroid, contentDescription = null) + Icon( + imageVector = MeshtasticIcons.PhoneAndroid, + contentDescription = stringResource(Res.string.config_device_use_phone_tz), + ) Spacer(modifier = Modifier.width(8.dp)) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt index 0e3c9058d..526bd63ef 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt @@ -30,6 +30,7 @@ import org.meshtastic.core.model.getColorFrom import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.export_tak_data_package import org.meshtastic.core.resources.tak import org.meshtastic.core.resources.tak_config import org.meshtastic.core.resources.tak_role @@ -74,7 +75,10 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onBack = onBack, actions = { IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) { - Icon(imageVector = MeshtasticIcons.Share, contentDescription = "Export TAK Data Package") + Icon( + imageVector = MeshtasticIcons.Share, + contentDescription = stringResource(Res.string.export_tak_data_package), + ) } }, configState = formState, diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt index 785654c71..015a4e08b 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -92,6 +92,7 @@ import org.meshtastic.core.resources.back import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.hide_password import org.meshtastic.core.resources.img_mpwrd_logo +import org.meshtastic.core.resources.mpwrd_os import org.meshtastic.core.resources.password import org.meshtastic.core.resources.show_password import org.meshtastic.core.resources.wifi_provision_available_networks @@ -513,7 +514,7 @@ internal fun MpwrdDisclaimerBanner() { ) { Image( painter = painterResource(Res.drawable.img_mpwrd_logo), - contentDescription = "mPWRD-OS", + contentDescription = stringResource(Res.string.mpwrd_os), modifier = Modifier.size(MPWRD_LOGO_SIZE_DP.dp).clip(RoundedCornerShape(8.dp)), ) AutoLinkText( From 9f3fe865e37f0e210c1d21da10cf035423dd9c67 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:35:41 -0500 Subject: [PATCH 05/27] test: migrate MigrationTest to runTest and add missing repository fakes (#5171) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pr5167.diff | 295 ++++++++++++++++++ .../core/database/dao/MigrationTest.kt | 12 +- .../testing/FakeDeviceHardwareRepository.kt | 69 ++++ .../testing/FakeFirmwareReleaseRepository.kt | 57 ++++ .../testing/FakeQuickChatActionRepository.kt | 71 +++++ .../core/testing/FakeRadioConfigRepository.kt | 162 ++++++++++ .../FakeTracerouteSnapshotRepository.kt | 55 ++++ .../core/testing/RepositoryFakesTest.kt | 129 ++++++++ 8 files changed, 844 insertions(+), 6 deletions(-) create mode 100644 .pr5167.diff create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt create mode 100644 core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt diff --git a/.pr5167.diff b/.pr5167.diff new file mode 100644 index 000000000..d0a809449 --- /dev/null +++ b/.pr5167.diff @@ -0,0 +1,295 @@ +diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +new file mode 100644 +index 0000000000..2a27b96906 +--- /dev/null ++++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +@@ -0,0 +1,39 @@ ++/* ++ * 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.common.di ++ ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.SupervisorJob ++import org.koin.core.annotation.Single ++import org.meshtastic.core.common.util.ioDispatcher ++ ++/** ++ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. ++ * ++ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled ++ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not ++ * cancel siblings, and by [ioDispatcher] so work runs off the main thread. ++ * ++ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch ++ * and should be used sparingly. ++ */ ++interface ApplicationCoroutineScope : CoroutineScope ++ ++@Single(binds = [ApplicationCoroutineScope::class]) ++internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { ++ override val coroutineContext = SupervisorJob() + ioDispatcher ++} +diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +index 231c84d401..5365ab95e2 100644 +--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt ++++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect + import co.touchlab.kermit.Logger + import com.eygraber.uri.toAndroidUri + import com.eygraber.uri.toKmpUri +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.jetbrains.compose.resources.StringResource + import org.jetbrains.compose.resources.getString + import org.meshtastic.core.common.gpsDisabled + import org.meshtastic.core.common.util.CommonUri ++import org.meshtastic.core.common.util.ioDispatcher + import java.net.URLEncoder + + @Composable +@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> + val context = LocalContext.current + return remember(context) { + { uri, maxChars -> +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + val androidUri = uri.toAndroidUri() +diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +index 031e1fe35d..a938f92ea6 100644 +--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt ++++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util + + import androidx.compose.runtime.Composable + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.jetbrains.compose.resources.StringResource + import org.meshtastic.core.common.util.CommonUri ++import org.meshtastic.core.common.util.ioDispatcher + import java.awt.Desktop + import java.awt.FileDialog + import java.awt.Frame +@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT + /** JVM — Reads text from a file URI. */ + @Composable + actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + val file = File(URI(uri.toString())) +diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +index dc1c459716..f8ff9fcac8 100644 +--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt ++++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch + import kotlinx.coroutines.withTimeoutOrNull + import org.jetbrains.compose.resources.StringResource + import org.koin.core.annotation.KoinViewModel ++import org.meshtastic.core.common.di.ApplicationCoroutineScope + import org.meshtastic.core.common.util.CommonUri + import org.meshtastic.core.common.util.safeCatching + import org.meshtastic.core.database.entity.FirmwareRelease +@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel( + private val firmwareUpdateManager: FirmwareUpdateManager, + private val usbManager: FirmwareUsbManager, + private val fileHandler: FirmwareFileHandler, ++ private val applicationScope: ApplicationCoroutineScope, + ) : ViewModel() { + + private val _state = MutableStateFlow(FirmwareUpdateState.Idle) +@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel( + + override fun onCleared() { + super.onCleared() +- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a +- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a +- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope +- // is cancelled concurrently. +- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) +- kotlinx.coroutines.GlobalScope.launch(NonCancellable) { ++ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the ++ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup ++ // running even if something tries to cancel it mid-flight. ++ applicationScope.launch(NonCancellable) { + tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) + } + } +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +index 4c48a1ced5..030d84effd 100644 +--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + @Test +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +index 7032ed4088..a8eddff838 100644 +--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + @Test +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +new file mode 100644 +index 0000000000..3ef5c44ef4 +--- /dev/null ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +@@ -0,0 +1,26 @@ ++/* ++ * 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.firmware ++ ++import kotlinx.coroutines.CoroutineDispatcher ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.SupervisorJob ++import org.meshtastic.core.common.di.ApplicationCoroutineScope ++ ++internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : ++ ApplicationCoroutineScope, ++ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) +diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +index acb1545bdd..23a0d03ab2 100644 +--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt ++++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + // ----------------------------------------------------------------------- +diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +index c251b4d5ef..315ad1da85 100644 +--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt ++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + import org.meshtastic.core.resources.Res + import org.meshtastic.core.resources.debug_export_failed + import org.meshtastic.core.resources.debug_export_success +@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + try { + if (logs.isEmpty()) { + withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } +diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +index 9afde85e5f..a28a576788 100644 +--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt ++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable + import androidx.compose.runtime.rememberCoroutineScope + import androidx.compose.ui.platform.LocalContext + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + + @Composable + actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { +@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr + return { fileName -> exportLauncher.launch(fileName) } + } + +-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { ++private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { + try { + context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } + Logger.i { "TAK data package exported successfully to $targetUri" } +diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +index 5b63cc90a3..a9a7285593 100644 +--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt ++++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging + import androidx.compose.runtime.Composable + import androidx.compose.runtime.rememberCoroutineScope + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + import java.awt.FileDialog + import java.awt.Frame + import java.io.File +@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr + if (directory != null && file != null) { + val targetFile = File(directory, file) + val data = dataPackageProvider() +- withContext(Dispatchers.IO) { targetFile.writeBytes(data) } ++ withContext(ioDispatcher) { targetFile.writeBytes(data) } + Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } + } + } diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 8062afa76..451a62174 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -20,7 +20,7 @@ import androidx.room3.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Before @@ -59,7 +59,7 @@ class MigrationTest { ) @Before - fun createDb(): Unit = runBlocking { + fun createDb(): Unit = runTest { val context = ApplicationProvider.getApplicationContext() database = Room.inMemoryDatabaseBuilder( @@ -77,7 +77,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking { + fun testMigrateChannelsByPSK_duplicatePSK() = runTest { // PSK \"AQ==\" is base64 for single byte 0x01 val pskBytes = byteArrayOf(0x01).toByteString() @@ -103,7 +103,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_reorder() = runBlocking { + fun testMigrateChannelsByPSK_reorder() = runTest { val pskA = byteArrayOf(0x01).toByteString() val pskB = byteArrayOf(0x02).toByteString() @@ -122,7 +122,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking { + fun testMigrateChannelsByPSK_disambiguateByName() = runTest { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A1") @@ -141,7 +141,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking { + fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A") diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt new file mode 100644 index 000000000..ef8cac0ba --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt @@ -0,0 +1,69 @@ +/* + * 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.testing + +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.repository.DeviceHardwareRepository + +/** + * A test double for [DeviceHardwareRepository] backed by an in-memory map keyed by `(hwModel, target)`. + * + * Call [setHardware] (or [setHardwareForModel]) to seed results, or [setResult] to control the exact [Result] returned + * for a given lookup. By default, lookups return `Result.success(null)`. + */ +class FakeDeviceHardwareRepository : + BaseFake(), + DeviceHardwareRepository { + + private val hardware = mutableMapOf, Result>() + private val calls = mutableListOf>() + + init { + registerResetAction { + hardware.clear() + calls.clear() + } + } + + /** Records every [getDeviceHardwareByModel] invocation for assertion. */ + val recordedCalls: List> + get() = calls.toList() + + override suspend fun getDeviceHardwareByModel( + hwModel: Int, + target: String?, + forceRefresh: Boolean, + ): Result { + calls.add(Triple(hwModel, target, forceRefresh)) + return hardware[hwModel to target] ?: hardware[hwModel to null] ?: Result.success(null) + } + + /** Seeds a successful lookup for the given model/target pair. */ + fun setHardware(hwModel: Int, target: String? = null, device: DeviceHardware?) { + hardware[hwModel to target] = Result.success(device) + } + + /** Seeds a successful lookup for any target of the given model. */ + fun setHardwareForModel(hwModel: Int, device: DeviceHardware?) { + hardware[hwModel to null] = Result.success(device) + } + + /** Seeds an arbitrary [Result] for the given lookup (use to test failure paths). */ + fun setResult(hwModel: Int, target: String? = null, result: Result) { + hardware[hwModel to target] = result + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt new file mode 100644 index 000000000..166256764 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt @@ -0,0 +1,57 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.repository.FirmwareReleaseRepository + +/** + * A test double for [FirmwareReleaseRepository] that exposes stable and alpha releases as + * [kotlinx.coroutines.flow.MutableStateFlow]s. + * + * Use [setStableRelease] and [setAlphaRelease] to drive the emitted values. + */ +class FakeFirmwareReleaseRepository : + BaseFake(), + FirmwareReleaseRepository { + + private val _stableRelease = mutableStateFlow(null) + private val _alphaRelease = mutableStateFlow(null) + + override val stableRelease: Flow = _stableRelease + override val alphaRelease: Flow = _alphaRelease + + var invalidateCacheCalls: Int = 0 + private set + + init { + registerResetAction { invalidateCacheCalls = 0 } + } + + override suspend fun invalidateCache() { + invalidateCacheCalls++ + } + + fun setStableRelease(release: FirmwareRelease?) { + _stableRelease.value = release + } + + fun setAlphaRelease(release: FirmwareRelease?) { + _alphaRelease.value = release + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt new file mode 100644 index 000000000..215542485 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt @@ -0,0 +1,71 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.repository.QuickChatActionRepository + +/** + * A test double for [QuickChatActionRepository] that keeps actions in an in-memory list (sorted by `position`). + * + * The in-memory list is exposed reactively through [getAllActions]. + */ +class FakeQuickChatActionRepository : + BaseFake(), + QuickChatActionRepository { + + private val actionsFlow = mutableStateFlow>(emptyList()) + + override fun getAllActions(): Flow> = actionsFlow + + override suspend fun upsert(action: QuickChatAction) { + val existingIndex = actionsFlow.value.indexOfFirst { it.uuid == action.uuid } + actionsFlow.value = + if (existingIndex >= 0) { + actionsFlow.value.toMutableList().also { it[existingIndex] = action } + } else { + actionsFlow.value + action + } + .sortedBy { it.position } + } + + override suspend fun deleteAll() { + actionsFlow.value = emptyList() + } + + override suspend fun delete(action: QuickChatAction) { + actionsFlow.value = + actionsFlow.value + .filterNot { it.uuid == action.uuid } + .map { if (it.position > action.position) it.copy(position = it.position - 1) else it } + } + + override suspend fun setItemPosition(uuid: Long, newPos: Int) { + actionsFlow.value = + actionsFlow.value.map { if (it.uuid == uuid) it.copy(position = newPos) else it }.sortedBy { it.position } + } + + /** Seeds the current list of actions (useful for test setup). */ + fun setActions(actions: List) { + actionsFlow.value = actions.sortedBy { it.position } + } + + /** Returns the current in-memory snapshot. */ + val currentActions: List + get() = actionsFlow.value +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt new file mode 100644 index 000000000..aa68e9b21 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt @@ -0,0 +1,162 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.FileInfo +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig + +/** + * A test double for [RadioConfigRepository] backed by in-memory [kotlinx.coroutines.flow.MutableStateFlow]s. + * + * All mutator methods update the underlying state flows synchronously so tests can observe changes immediately. + * [deviceProfileFlow] is derived from [localConfigFlow], [moduleConfigFlow], and the current channel set. + */ +@Suppress("TooManyFunctions") +class FakeRadioConfigRepository : + BaseFake(), + RadioConfigRepository { + + private val channelSetBacking = mutableStateFlow(ChannelSet()) + override val channelSetFlow: Flow = channelSetBacking + + private val localConfigBacking = mutableStateFlow(LocalConfig()) + override val localConfigFlow: Flow = localConfigBacking + + private val moduleConfigBacking = mutableStateFlow(LocalModuleConfig()) + override val moduleConfigFlow: Flow = moduleConfigBacking + + private val deviceProfileBacking = mutableStateFlow(DeviceProfile()) + override val deviceProfileFlow: Flow = deviceProfileBacking + val currentDeviceProfile: DeviceProfile + get() = deviceProfileBacking.value + + private val deviceUIConfigBacking = mutableStateFlow(null) + override val deviceUIConfigFlow: Flow = deviceUIConfigBacking + + private val fileManifestBacking = mutableStateFlow>(emptyList()) + override val fileManifestFlow: Flow> = fileManifestBacking + + val currentChannelSet: ChannelSet + get() = channelSetBacking.value + + val currentLocalConfig: LocalConfig + get() = localConfigBacking.value + + val currentModuleConfig: LocalModuleConfig + get() = moduleConfigBacking.value + + val currentDeviceUIConfig: DeviceUIConfig? + get() = deviceUIConfigBacking.value + + val currentFileManifest: List + get() = fileManifestBacking.value + + /** + * Last [Config] passed to [setLocalConfig] (null until called). Tests should use [setLocalConfigDirect] to drive + * state. + */ + var lastSetLocalConfig: Config? = null + private set + + /** Last [ModuleConfig] passed to [setLocalModuleConfig] (null until called). */ + var lastSetModuleConfig: ModuleConfig? = null + private set + + init { + registerResetAction { + lastSetLocalConfig = null + lastSetModuleConfig = null + } + } + + override suspend fun clearChannelSet() { + channelSetBacking.value = ChannelSet() + } + + override suspend fun replaceAllSettings(settingsList: List) { + channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList) + } + + override suspend fun updateChannelSettings(channel: Channel) { + val current = channelSetBacking.value.settings.toMutableList() + while (current.size <= channel.index) current.add(ChannelSettings()) + current[channel.index] = channel.settings ?: ChannelSettings() + channelSetBacking.value = channelSetBacking.value.copy(settings = current) + } + + override suspend fun clearLocalConfig() { + localConfigBacking.value = LocalConfig() + } + + override suspend fun setLocalConfig(config: Config) { + lastSetLocalConfig = config + } + + override suspend fun clearLocalModuleConfig() { + moduleConfigBacking.value = LocalModuleConfig() + } + + override suspend fun setLocalModuleConfig(config: ModuleConfig) { + lastSetModuleConfig = config + } + + override suspend fun setDeviceUIConfig(config: DeviceUIConfig) { + deviceUIConfigBacking.value = config + } + + override suspend fun clearDeviceUIConfig() { + deviceUIConfigBacking.value = null + } + + override suspend fun addFileInfo(info: FileInfo) { + fileManifestBacking.value = fileManifestBacking.value + info + } + + override suspend fun clearFileManifest() { + fileManifestBacking.value = emptyList() + } + + /** Directly sets the [LocalConfig] without merging (preferred for test setup). */ + fun setLocalConfigDirect(config: LocalConfig) { + localConfigBacking.value = config + } + + /** Directly sets the [LocalModuleConfig] without merging (preferred for test setup). */ + fun setLocalModuleConfigDirect(config: LocalModuleConfig) { + moduleConfigBacking.value = config + } + + /** Directly sets the combined [DeviceProfile] emitted by [deviceProfileFlow]. */ + fun setDeviceProfile(profile: DeviceProfile) { + deviceProfileBacking.value = profile + } + + /** Directly sets the [ChannelSet] (bypasses [updateChannelSettings]/[replaceAllSettings]). */ + fun setChannelSet(channelSet: ChannelSet) { + channelSetBacking.value = channelSet + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt new file mode 100644 index 000000000..a52b86bd0 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt @@ -0,0 +1,55 @@ +/* + * 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.testing + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.meshtastic.core.repository.TracerouteSnapshotRepository +import org.meshtastic.proto.Position + +/** + * A test double for [TracerouteSnapshotRepository] keyed by `logUuid`. + * + * Use [upsertSnapshotPositions] as you would in production, or [seedSnapshot] to directly inject state for a log. + */ +class FakeTracerouteSnapshotRepository : + BaseFake(), + TracerouteSnapshotRepository { + + private val snapshots = mutableStateFlow>>(emptyMap()) + private val requestIds = mutableMapOf() + + init { + registerResetAction { requestIds.clear() } + } + + override fun getSnapshotPositions(logUuid: String): Flow> = + snapshots.map { it[logUuid].orEmpty() } + + override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) { + requestIds[logUuid] = requestId + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Directly seeds the snapshot for a log (bypasses request-id tracking). */ + fun seedSnapshot(logUuid: String, positions: Map) { + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Returns the last request-id recorded for [logUuid], or `null` if none. */ + fun lastRequestId(logUuid: String): Int? = requestIds[logUuid] +} diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt new file mode 100644 index 000000000..f9a63c712 --- /dev/null +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt @@ -0,0 +1,129 @@ +/* + * 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.testing + +import app.cash.turbine.test +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RepositoryFakesTest { + + @Test + fun `FakeDeviceHardwareRepository returns seeded hardware and records calls`() = runTest { + val repo = FakeDeviceHardwareRepository() + val hw = DeviceHardware(hwModel = 42, hwModelSlug = "TEST", platformioTarget = "tlora") + repo.setHardware(hwModel = 42, target = "tlora", device = hw) + + val hit = repo.getDeviceHardwareByModel(hwModel = 42, target = "tlora", forceRefresh = false) + val miss = repo.getDeviceHardwareByModel(hwModel = 99) + + assertEquals(hw, hit.getOrNull()) + assertNull(miss.getOrNull()) + assertEquals(2, repo.recordedCalls.size) + assertEquals(Triple(42, "tlora", false), repo.recordedCalls.first()) + } + + @Test + fun `FakeFirmwareReleaseRepository emits stable and alpha releases`() = runTest { + val repo = FakeFirmwareReleaseRepository() + val stable = FirmwareRelease(id = "1.0", title = "1.0", pageUrl = "", zipUrl = "") + val alpha = FirmwareRelease(id = "1.1-a", title = "1.1-a", pageUrl = "", zipUrl = "") + + repo.setStableRelease(stable) + repo.setAlphaRelease(alpha) + + assertEquals(stable, repo.stableRelease.first()) + assertEquals(alpha, repo.alphaRelease.first()) + + repo.invalidateCache() + repo.invalidateCache() + assertEquals(2, repo.invalidateCacheCalls) + } + + @Test + fun `FakeQuickChatActionRepository upsert delete and reorder`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "hi", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "bye", position = 1) + + repo.upsert(a) + repo.upsert(b) + assertEquals(listOf(a, b), repo.getAllActions().first()) + + repo.setItemPosition(uuid = 1L, newPos = 5) + assertEquals(listOf(2L, 1L), repo.getAllActions().first().map { it.uuid }) + + repo.delete(b) + assertEquals(1, repo.currentActions.size) + + repo.deleteAll() + assertTrue(repo.currentActions.isEmpty()) + } + + @Test + fun `FakeQuickChatActionRepository delete compacts positions`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "", position = 1) + val c = QuickChatAction(uuid = 3L, name = "C", message = "", position = 2) + repo.upsert(a) + repo.upsert(b) + repo.upsert(c) + + repo.delete(b) + + // Matches real DAO's decrementPositionsAfter: positions must stay contiguous. + assertEquals(listOf(1L to 0, 3L to 1), repo.currentActions.map { it.uuid to it.position }) + } + + @Test + fun `FakeTracerouteSnapshotRepository roundtrips positions keyed by log uuid`() = runTest { + val repo = FakeTracerouteSnapshotRepository() + val positions = mapOf(1 to Position(latitude_i = 10), 2 to Position(latitude_i = 20)) + repo.upsertSnapshotPositions(logUuid = "log-1", requestId = 99, positions = positions) + + repo.getSnapshotPositions("log-1").test { assertEquals(positions, awaitItem()) } + assertEquals(99, repo.lastRequestId("log-1")) + assertNull(repo.lastRequestId("other")) + } + + @Test + fun `FakeRadioConfigRepository tracks channel set and module config`() = runTest { + val repo = FakeRadioConfigRepository() + val a = ChannelSettings(name = "A") + val b = ChannelSettings(name = "B") + + repo.replaceAllSettings(listOf(a, b)) + assertEquals(listOf(a, b), repo.currentChannelSet.settings) + + repo.updateChannelSettings(Channel(index = 1, settings = ChannelSettings(name = "B2"))) + assertEquals("B2", repo.currentChannelSet.settings[1].name) + + repo.clearChannelSet() + assertTrue(repo.currentChannelSet.settings.isEmpty()) + } +} From b979663e24702d306225f48016842bd312f631a1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:13:01 -0500 Subject: [PATCH 06/27] refactor: consolidate metric formatting through MetricFormatter (#5169) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/common/util/MetricFormatter.kt | 7 ++++ .../core/common/util/MetricFormatterTest.kt | 20 +++++++++++ .../node/metrics/EnvironmentMetrics.kt | 34 ++++++++++++++----- .../radio/component/LoadingOverlay.kt | 4 +-- .../component/PacketResponseStateDialog.kt | 4 +-- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt index 8e57b4dbb..51905ff41 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt @@ -23,6 +23,7 @@ package org.meshtastic.core.common.util * All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional * for a mesh networking app where consistency matters. */ +@Suppress("TooManyFunctions") object MetricFormatter { fun temperature(celsius: Float, isFahrenheit: Boolean): String { @@ -47,6 +48,12 @@ object MetricFormatter { fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB" fun rssi(value: Int): String = "$value dBm" + + fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String = + "${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s" + + fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String = + "${NumberFormatter.format(millimeters, decimalPlaces)} mm" } private const val FAHRENHEIT_SCALE = 1.8f diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt index b602a4a62..94781fca3 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt @@ -120,4 +120,24 @@ class MetricFormatterTest { fun snrNegative() { assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f)) } + + @Test + fun windSpeed() { + assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f)) + } + + @Test + fun windSpeedZero() { + assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f)) + } + + @Test + fun rainfall() { + assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f)) + } + + @Test + fun rainfallZero() { + assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f)) + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 77c6781f1..d09bdc8d1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType @@ -165,7 +166,10 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.HUMIDITY.color) Spacer(Modifier.width(4.dp)) Text( - text = formatString("%s %.2f%%", stringResource(Res.string.humidity), humidity), + text = + "${stringResource( + Res.string.humidity, + )} ${MetricFormatter.percent(humidity, decimalPlaces = 2)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -178,7 +182,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.BAROMETRIC_PRESSURE.color) Spacer(Modifier.width(4.dp)) Text( - text = formatString("%.2f hPa", pressure), + text = MetricFormatter.pressure(pressure, decimalPlaces = 2), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -286,7 +290,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasVoltage) { val voltage = envMetrics.voltage!! Text( - text = formatString("%s %.2f V", stringResource(Res.string.voltage), voltage), + text = "${stringResource(Res.string.voltage)} ${MetricFormatter.voltage(voltage)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -294,7 +298,10 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasCurrent) { val currentValue = envMetrics.current!! Text( - text = formatString("%s %.2f mA", stringResource(Res.string.current), currentValue), + text = + "${stringResource( + Res.string.current, + )} ${MetricFormatter.current(currentValue, decimalPlaces = 2)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -387,7 +394,11 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { envMetrics.wind_direction!!, ) } else { - formatString("%s %.1f m/s", stringResource(Res.string.wind_speed), envMetrics.wind_speed!!) + formatString( + "%s %s", + stringResource(Res.string.wind_speed), + MetricFormatter.windSpeed(envMetrics.wind_speed!!), + ) } Text( text = dirText, @@ -403,14 +414,14 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (hasGust) { Text( - text = formatString("%s %.1f m/s", stringResource(Res.string.wind_gust), envMetrics.wind_gust!!), + text = "${stringResource(Res.string.wind_gust)} ${MetricFormatter.windSpeed(envMetrics.wind_gust!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) } if (hasLull) { Text( - text = formatString("%s %.1f m/s", stringResource(Res.string.wind_lull), envMetrics.wind_lull!!), + text = "${stringResource(Res.string.wind_lull)} ${MetricFormatter.windSpeed(envMetrics.wind_lull!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -427,7 +438,10 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (has1h) { Text( - text = formatString("%s %.1f mm", stringResource(Res.string.rainfall_1h), envMetrics.rainfall_1h!!), + text = + "${stringResource( + Res.string.rainfall_1h, + )} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -435,7 +449,9 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) if (has24h) { Text( text = - formatString("%s %.1f mm", stringResource(Res.string.rainfall_24h), envMetrics.rainfall_24h!!), + "${stringResource( + Res.string.rainfall_24h, + )} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt index 8039dc37d..2646b20cb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.feature.settings.radio.ResponseState private const val LOADING_OVERLAY_ALPHA = 0.8f @@ -73,7 +73,7 @@ fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) { trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Text( - text = formatString("%.0f%%", progress * PERCENTAGE_FACTOR), + text = MetricFormatter.percent(progress * PERCENTAGE_FACTOR, decimalPlaces = 0), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt index 18d79e08f..c319c4f7f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.close @@ -111,7 +111,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) val progress by animateFloatAsState(targetValue = clampedProgress, label = "progress") Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = formatString("%.0f%%", progress * 100f), + text = MetricFormatter.percent(progress * 100f, decimalPlaces = 0), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.secondary, ) From 15a7c19b74c4b97875098975e3abab88a08022ab Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:13:26 -0500 Subject: [PATCH 07/27] chore(r8): remove redundant keep rules covered by consumer rules (#5172) Co-authored-by: GitHub Copilot CLI <223556219+Copilot@users.noreply.github.com> --- config/proguard/shared-rules.pro | 100 ++++++++++--------------------- core/model/consumer-rules.pro | 2 - core/proto/consumer-rules.pro | 43 ------------- desktop/proguard-rules.pro | 5 +- 4 files changed, 36 insertions(+), 114 deletions(-) delete mode 100644 core/model/consumer-rules.pro delete mode 100644 core/proto/consumer-rules.pro diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro index fada20be3..8d0d8efde 100644 --- a/config/proguard/shared-rules.pro +++ b/config/proguard/shared-rules.pro @@ -20,12 +20,10 @@ -keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations # ---- Kotlin / Coroutines ---------------------------------------------------- - --keep class kotlin.Metadata { *; } --keep class kotlin.reflect.** { *; } --keep class kotlin.coroutines.Continuation { *; } --keep class kotlinx.coroutines.** { *; } --dontwarn kotlinx.coroutines.** +# Kotlin stdlib and kotlinx-coroutines ship their own consumer ProGuard rules +# (kotlin-stdlib and kotlinx-coroutines-core consumer-rules.pro) which keep +# Metadata, Continuation, kotlin.reflect internals, and debug metadata. No +# explicit wildcards needed here. # ---- Koin DI (reflection-based injection) ----------------------------------- @@ -41,9 +39,7 @@ -keep @org.koin.core.annotation.ComponentScan class * { *; } -keep @org.koin.core.annotation.Single class * { *; } -keep @org.koin.core.annotation.Factory class * { *; } - -# Generated Koin module extensions (Koin Annotations plugin output) --keep class org.meshtastic.**.di.** { *; } +-keep @org.koin.core.annotation.KoinViewModel class * { *; } # ---- kotlinx-serialization -------------------------------------------------- @@ -63,13 +59,14 @@ # ---- Wire Protobuf ---------------------------------------------------------- -# Wire generates ADAPTER companion objects accessed via reflection --keep class com.squareup.wire.** { *; } --dontwarn com.squareup.wire.** - -# Generated proto message classes (both meshtastic protos and internal package) --keep class org.meshtastic.proto.** { *; } --keep class meshtastic.** { *; } +# Wire generates an ADAPTER static field on every Message subclass accessed +# reflectively during encoding/decoding. Keep those fields and the +# ProtoAdapter subclasses themselves; Wire's bundled consumer rules preserve +# the runtime itself. +-keepclassmembers class * extends com.squareup.wire.Message { + public static *** ADAPTER; +} +-keepclassmembers class * extends com.squareup.wire.ProtoAdapter { *; } # Suppress warnings about missing Android Parcelable (Wire cross-platform stubs # when compiling for non-Android JVM targets; harmless on Android). @@ -86,40 +83,24 @@ -keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } -keep class org.meshtastic.core.database.MeshtasticDatabase { *; } -# Room DAOs — Room generates implementations at compile time; keep interfaces --keep class org.meshtastic.core.database.dao.** { *; } - -# Room Entities — accessed via reflection for column mapping --keep class org.meshtastic.core.database.entity.** { *; } - -# Room TypeConverters — invoked reflectively --keep class org.meshtastic.core.database.Converters { *; } - -# Room generated _Impl classes --keep class **_Impl { *; } +# Room's own consumer rules (from androidx.room3) keep DAOs, entities, +# generated _Impl classes, and TypeConverters referenced from the database. # ---- SQLite bundled -------------------------------------------------------- - --keep class androidx.sqlite.** { *; } --dontwarn androidx.sqlite.** +# androidx.sqlite ships consumer rules. # ---- Ktor (ServiceLoader + plugin discovery) -------------------------------- --keep class io.ktor.** { *; } --dontwarn io.ktor.** - -# Keep ServiceLoader metadata files +# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory +# implementations reflectively via ServiceLoader). -keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } # ---- Coil 3 (image loading) ------------------------------------------------- - --keep class coil3.** { *; } --dontwarn coil3.** +# coil3 ships consumer rules. # ---- Kable BLE -------------------------------------------------------------- - --keep class com.juul.kable.** { *; } --dontwarn com.juul.kable.** +# com.juul.kable ships consumer rules; if release builds fail with missing +# Kable classes, restore a narrow keep for the specific reflection-loaded type. # ---- Compose Multiplatform resources ---------------------------------------- @@ -127,17 +108,14 @@ # Without these the fdroid flavor has crashed at startup with a misleading # URLDecodeException due to R8 exception-class merging. -keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.** { *; } +-keep class org.meshtastic.core.resources.Res { *; } +-keepclassmembers class org.meshtastic.core.resources.Res$* { *; } # ---- AboutLibraries --------------------------------------------------------- - --keep class com.mikepenz.aboutlibraries.** { *; } --dontwarn com.mikepenz.aboutlibraries.** +# com.mikepenz.aboutlibraries ships consumer rules. # ---- Multiplatform Markdown Renderer ---------------------------------------- - --keep class com.mikepenz.markdown.** { *; } --dontwarn com.mikepenz.markdown.** +# com.mikepenz.markdown ships consumer rules. # ---- QR Code Kotlin --------------------------------------------------------- @@ -147,36 +125,24 @@ -dontwarn qrcode.** # ---- Kermit logging --------------------------------------------------------- - --keep class co.touchlab.kermit.** { *; } --dontwarn co.touchlab.kermit.** +# co.touchlab.kermit ships consumer rules. # ---- Okio ------------------------------------------------------------------- - --keep class okio.** { *; } --dontwarn okio.** +# okio ships consumer rules. # ---- DataStore -------------------------------------------------------------- - --keep class androidx.datastore.** { *; } --dontwarn androidx.datastore.** +# androidx.datastore ships consumer rules. # ---- Paging ----------------------------------------------------------------- - --keep class androidx.paging.** { *; } --dontwarn androidx.paging.** +# androidx.paging ships consumer rules. # ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) ----------------- - --keep class androidx.lifecycle.** { *; } --keep class androidx.navigation3.** { *; } --dontwarn androidx.lifecycle.** --dontwarn androidx.navigation3.** +# androidx.lifecycle and androidx.navigation3 ship consumer rules. # ---- Meshtastic shared model ------------------------------------------------ - -# Core model classes (used in serialization, Room, and Koin injection) --keep class org.meshtastic.core.model.** { *; } +# core.model types are reached via static references from Koin-wired graphs, +# Room entities, and kotlinx-serialization @Serializable companions — all of +# which have their own keep rules above. # ---- Compose Runtime & Animation -------------------------------------------- diff --git a/core/model/consumer-rules.pro b/core/model/consumer-rules.pro deleted file mode 100644 index 5f75d687d..000000000 --- a/core/model/consumer-rules.pro +++ /dev/null @@ -1,2 +0,0 @@ --keep class org.meshtastic.core.model.DataPacket --keep class org.meshtastic.core.model.DataPacket$CREATOR diff --git a/core/proto/consumer-rules.pro b/core/proto/consumer-rules.pro deleted file mode 100644 index e9dc3751a..000000000 --- a/core/proto/consumer-rules.pro +++ /dev/null @@ -1,43 +0,0 @@ -# Core proto classes required for packet handling and serialization -# FromRadio and related message types (primary packet container) --keep class org.meshtastic.proto.FromRadio --keep class org.meshtastic.proto.Data --keep class org.meshtastic.proto.MeshPacket --keep class org.meshtastic.proto.LogRecord - -# Message type payloads (handled in packet routing) --keep class org.meshtastic.proto.AdminMessage --keep class org.meshtastic.proto.StoreAndForward --keep class org.meshtastic.proto.StoreForwardPlusPlus --keep class org.meshtastic.proto.Routing - -# User and Node information --keep class org.meshtastic.proto.User --keep class org.meshtastic.proto.NeighborInfo --keep class org.meshtastic.proto.Neighbor - -# Location and environment data --keep class org.meshtastic.proto.Position --keep class org.meshtastic.proto.Waypoint --keep class org.meshtastic.proto.StatusMessage - -# Telemetry data types --keep class org.meshtastic.proto.Telemetry --keep class org.meshtastic.proto.DeviceMetrics --keep class org.meshtastic.proto.EnvironmentMetrics --keep class org.meshtastic.proto.AirQualityMetrics --keep class org.meshtastic.proto.PowerMetrics --keep class org.meshtastic.proto.LocalStats --keep class org.meshtastic.proto.HostMetrics - -# Other data --keep class org.meshtastic.proto.Paxcount --keep class org.meshtastic.proto.DeviceMetadata - -# Configuration classes --keep class org.meshtastic.proto.ChannelSet --keep class org.meshtastic.proto.LocalConfig --keep class org.meshtastic.proto.Config --keep class org.meshtastic.proto.ModuleConfig --keep class org.meshtastic.proto.Channel --keep class org.meshtastic.proto.ClientNotification diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index 9e23e32c7..280214b2e 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -46,8 +46,9 @@ -keep class org.meshtastic.desktop.MainKt { *; } # ---- Ktor Java engine (desktop-only; Android uses OkHttp) ------------------- - --keep class io.ktor.client.engine.java.** { *; } +# io.ktor.client.engine.java ships consumer rules; the shared +# HttpClientEngineFactory ServiceLoader keep in shared-rules.pro covers the +# reflective discovery path. # ---- Meshtastic desktop host shell ------------------------------------------ From 56cbc3670ddec733ced6084cc2db2abaf40340dc Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:13:27 -0500 Subject: [PATCH 08/27] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5163) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-ar/strings.xml | 1 + .../composeResources/values-be/strings.xml | 2 ++ .../composeResources/values-bg/strings.xml | 10 ++++++++++ .../composeResources/values-ca/strings.xml | 1 + .../composeResources/values-cs/strings.xml | 2 ++ .../composeResources/values-de/strings.xml | 2 ++ .../composeResources/values-el/strings.xml | 1 + .../composeResources/values-es/strings.xml | 2 ++ .../composeResources/values-et/strings.xml | 5 +++++ .../composeResources/values-fi/strings.xml | 5 +++++ .../composeResources/values-fr/strings.xml | 2 ++ .../composeResources/values-ga/strings.xml | 1 + .../composeResources/values-gl/strings.xml | 1 + .../composeResources/values-he/strings.xml | 1 + .../composeResources/values-hr/strings.xml | 1 + .../composeResources/values-ht/strings.xml | 1 + .../composeResources/values-hu/strings.xml | 2 ++ .../composeResources/values-is/strings.xml | 1 + .../composeResources/values-it/strings.xml | 2 ++ .../composeResources/values-ja/strings.xml | 2 ++ .../composeResources/values-ko/strings.xml | 2 ++ .../composeResources/values-lt/strings.xml | 1 + .../composeResources/values-nl/strings.xml | 2 ++ .../composeResources/values-no/strings.xml | 1 + .../composeResources/values-pl/strings.xml | 2 ++ .../composeResources/values-pt-rBR/strings.xml | 2 ++ .../composeResources/values-pt/strings.xml | 2 ++ .../composeResources/values-ro/strings.xml | 2 ++ .../composeResources/values-ru/strings.xml | 2 ++ .../composeResources/values-sk/strings.xml | 2 ++ .../composeResources/values-sl/strings.xml | 1 + .../composeResources/values-sq/strings.xml | 1 + .../composeResources/values-sr/strings.xml | 2 ++ .../composeResources/values-srp/strings.xml | 2 ++ .../composeResources/values-sv/strings.xml | 2 ++ .../composeResources/values-tr/strings.xml | 2 ++ .../composeResources/values-uk/strings.xml | 2 ++ .../composeResources/values-zh-rCN/strings.xml | 2 ++ .../composeResources/values-zh-rTW/strings.xml | 15 +++++++++++++++ 39 files changed, 92 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml index fc61e78d4..0aabb2f37 100644 --- a/core/resources/src/commonMain/composeResources/values-ar/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ar/strings.xml @@ -154,6 +154,7 @@ الرسائل إعدادات لورا الجهة + انقطع الاتصال استغرق وقت طويل المسافة الإعدادات diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml index 301ff3bb4..03216a10a 100644 --- a/core/resources/src/commonMain/composeResources/values-be/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml @@ -167,6 +167,8 @@ Граць LoRa Рэгіён + Адлучана + Злучаны Імя карыстальніка Пароль Уключана diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index fe1520458..0a1478bb2 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -201,10 +201,15 @@ Възстановяване на настройките по подразбиране Приложи Тема + Контраст Светла Тъмна По подразбиране на системата Избор на тема + Ниво на контраста + Стандартен + Среден + Висок Изпращане на местоположение в мрежата Компактно кодиране за Кирилица @@ -301,6 +306,8 @@ Батерия Използване на канала Използване на ефира + %1$s: %2$s%% + %1$s: %2$s V %1$s %1$s: %2$s записа @@ -483,6 +490,8 @@ Честотен слот Игнориране на MQTT Конфигуриране на MQTT + Прекъсната връзка + Свързано MQTT е активиран Адрес Потребителско име @@ -960,5 +969,6 @@ Въведете или изберете мрежа WiFi е конфигуриран успешно! Прилагането на конфигурацията за WiFi не е успешно + Изход Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-ca/strings.xml b/core/resources/src/commonMain/composeResources/values-ca/strings.xml index 485e1c9e1..d2ee2550b 100644 --- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml @@ -182,6 +182,7 @@ Sempre Traçar ruta Regió + Desconnectat Temps esgotat Distància Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index 6220218ac..66e9d84d8 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -525,6 +525,8 @@ Ignorovat MQTT OK do MQTT Nastavení MQTT + Odpojeno + Připojeno MQTT povoleno Adresa Uživatelské jméno diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 161feaa3e..4d1161573 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -613,6 +613,8 @@ MQTT ignorieren OK für MQTT MQTT Einstellungen + Verbindung getrennt + Verbunden MQTT aktiviert Adresse Benutzername diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml index 88feab55e..6d691ec4d 100644 --- a/core/resources/src/commonMain/composeResources/values-el/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml @@ -164,6 +164,7 @@ Μηνύματα LoRa Περιφέρεια + Αποσυνδεδεμένο Διεύθυνση Όνομα χρήστη Κωδικός πρόσβασης diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index 3470f7bed..d6505500f 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -492,6 +492,8 @@ Rango de Valores 0 - 500. Ignorar Paquetes MQTT Permitir MQTT Configuración MQTT + Desconectado + Conectado Activar el MQTT Dirección del Servidor MQTT Usuario diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 650c69122..9833e43a3 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -613,6 +613,8 @@ Keela MQTT Ok MQTTi MQTT sätted + Ühendus katkenud + Ühendatud MQTT lubatud Aadress Kasutajatunnus @@ -1208,5 +1210,8 @@ Sisestage või valige võrk WiFi edukalt seadistatud! WiFi sätete rakendamine ebaõnnestus + Meshtastic töölaud + Näita Meshtastic + Sule Kärgvõrgustik diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index 3d9e28e91..b7c861b5e 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -613,6 +613,8 @@ Ohita MQTT MQTT päällä MQTT asetukset + Ei yhdistetty + Yhdistetty MQTT käytössä Osoite Käyttäjänimi @@ -1209,5 +1211,8 @@ Syötä tai valitse verkko WiFi määritetty onnistuneesti! WiFi-asetusten käyttöönotto epäonnistui + Meshtastic työpöytä + Näytä Meshtastic + Lopeta Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index 0ef821cb6..f39208b94 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -554,6 +554,8 @@ Ignorer MQTT Transmission des paquets vers MQTT Configuration MQTT + Déconnecté + Connecté MQTT activé Adresse Nom d'utilisateur diff --git a/core/resources/src/commonMain/composeResources/values-ga/strings.xml b/core/resources/src/commonMain/composeResources/values-ga/strings.xml index a081daff2..7ddebc824 100644 --- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml @@ -213,6 +213,7 @@ Céimeanna i dtreo %1$d Céimeanna ar ais %2$d Réigiún + Na ceangailte Am tráth Sáth diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml index cc3c02597..0dc8fd892 100644 --- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml @@ -149,6 +149,7 @@ Sempre Traza-ruta Rexión + Desconectado Distancia diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml index 3afe39071..7239680f1 100644 --- a/core/resources/src/commonMain/composeResources/values-he/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml @@ -133,6 +133,7 @@ בדיקת מסלול הודעות אזור + מנותק מרחק הגדרות diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml index aae7d6690..6753d3559 100644 --- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml @@ -150,6 +150,7 @@ Detalji Crveno Regija + Odspojeno Udaljenost Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml index 7c4fc0f24..9c2b3beca 100644 --- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml @@ -201,6 +201,7 @@ Direk Hops vèsus %1$d Hops tounen %2$d Rejyon + Dekonekte Tan pase Distans diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml index f553a6a32..b0270a8ab 100644 --- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml @@ -515,6 +515,8 @@ MQTT figyelmen kívül hagyása MQTT-re továbbítható MQTT beállítások + Szétkapcsolva + Csatlakoztatva MQTT engedélyezve Cím Felhasználónév diff --git a/core/resources/src/commonMain/composeResources/values-is/strings.xml b/core/resources/src/commonMain/composeResources/values-is/strings.xml index 4e07e1c2a..ce8853250 100644 --- a/core/resources/src/commonMain/composeResources/values-is/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-is/strings.xml @@ -119,6 +119,7 @@ Hámarsksendingartíma náð. Ekki hægt að senda skilaboð, vinsamlegast reynið aftur síðar. Ferilkönnun Svæði + Aftengd diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index 744741047..903b098d7 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -560,6 +560,8 @@ Ignora MQTT OK per MQTT Configurazione MQTT + Disconnesso + Connesso MQTT abilitato Indirizzo Username diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml index 59b54d2f5..943ea2d90 100644 --- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml @@ -424,6 +424,8 @@ PAファン無効 MQTT を無視 MQTT設定 + 切断 + 接続済 MQTTを有効化 アドレス ユーザー名 diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml index 0a5bc4031..bc8f6bb3f 100644 --- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml @@ -373,6 +373,8 @@ PA fan 비활성화됨 MQTT로 부터 수신 무시 MQTT 설정 + 연결 끊김 + 연결됨 MQTT 활성화 서버 주소 사용자명 diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml index 9592d8b14..99f7fd3cf 100644 --- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml @@ -217,6 +217,7 @@ Skambučio simbolis! Raudona Regionas + Atsijungta Viešasis raktas Privatus raktas Baigėsi laikas diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml index ee07fb52b..4ef65fa1c 100644 --- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml @@ -312,6 +312,8 @@ Inkomende negeren Negeer MQTT MQTT Configuratie + Niet verbonden + Verbonden MQTT ingeschakeld Adres Gebruikersnaam diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml index 2ecd2a425..d539af4f1 100644 --- a/core/resources/src/commonMain/composeResources/values-no/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml @@ -222,6 +222,7 @@ Kopier Varsel, bjellekarakter! Region + Frakoblet Offentlig nøkkel Privat nøkkel Tidsavbrudd diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 272f515f8..32055e52a 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -499,6 +499,8 @@ Zignoruj MQTT Ok dla MQTT Konfiguracja MQTT + Rozłączono + Połączony Włącz MQTT Adres Nazwa użytkownika diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml index 521a84b48..dafb3b034 100644 --- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -382,6 +382,8 @@ Ventilador do PA desativado Ignorar MQTT Configurações MQTT + Desconectado + Conectado MQTT habilitado Endereço Nome de usuário diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml index 545bd4e6f..732a71dca 100644 --- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml @@ -366,6 +366,8 @@ Ignorar entrada Ignorar MQTT Configuração MQTT + Desconectado + Ligado MQTT ativo Endereço Utilizador diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index 984a939d8..aac8b4e16 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -599,6 +599,8 @@ Ignoră MQTT Acceptă MQTT Configurare MQTT + Deconectat + Conectat MQTT activat Adresă Nume de utilizator diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index dd0d4a53f..cf0b1d421 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -621,6 +621,8 @@ Игнорировать MQTT ОК в MQTT Настройка MQTT + Отключено + Подключено MQTT включен Адрес Имя пользователя diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml index e51ef506d..c6dd2ee2d 100644 --- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml @@ -362,6 +362,8 @@ LoRa Šírka pásma Región + Odpojené + Pripojený Adresa Používateľské meno Heslo diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml index 8025c4751..3605549aa 100644 --- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml @@ -226,6 +226,7 @@ Kopiraj Znak opozorilnega zvonca! Regija + Prekinjeno Javni ključ Zasebni ključ Časovna omejitev diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml index e70391f4d..ec38d179c 100644 --- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml @@ -202,6 +202,7 @@ Hops drejt %1$d Hops prapa %2$d 訊息 Rajon + I shkëputur Koha e skaduar Distanca diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index 21a9c14c1..7c1eff713 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -349,6 +349,8 @@ Игнориши MQTT Позитиван за MQTT MQTT подешавања + Raskačeno + Блутут повезан Адреса Корисничко име Лозинка diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index 516a963f4..9e8c0ce9b 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -349,6 +349,8 @@ Игнориши MQTT Позитиван за MQTT MQTT подешавања + Раскачено + Блутут повезан Адреса Корисничко име Лозинка diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index 27f368d7e..d970a394a 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -529,6 +529,8 @@ Ignorera MQTT Ok till MQTT MQTT-konfiguration + Frånkopplad + Ansluten MQTT är aktiverat Adress Användarnamn diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml index a3ae53c8c..5dd6adbfd 100644 --- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml @@ -366,6 +366,8 @@ PA fanı devre dışı MQTT'yi Yoksay MQTT Yapılandırması + Bağlantı kesildi + Bağlandı MQTT etkin Adres Kullanıcı adı diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index 3e96490dc..c99e8d45f 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -401,6 +401,8 @@ Перевизначити частоту Ігнорувати MQTT Налаштування MQTT + Відключено + Під’єднано MQTT увімкнений Адреса Ім'я користувача diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml index 87feeb9e2..b8a1a9f0c 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -568,6 +568,8 @@ 忽略 MQTT 使用MQTT MQTT设置 + 已断开连接 + 已连接 启用MQTT 地址 用户名 diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml index 354415089..950ffeba8 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -45,6 +45,7 @@ 無法識別 正在等待確認 發送佇列中 + 已傳送至 Mesh 不明 透過 SF++ 鏈路由… 已在 SF++ 鏈上確認 @@ -122,6 +123,7 @@ 嘗試獲取 GPS 位置的頻率(< 10 秒將保持 GPS 模組開啟)。 位置訊息可選的附加欄位。包含的欄位越多,訊息越大,將造成空中時間拉長和封包遺失的風險增加。 將盡可能使所有元件進入睡眠狀態。對於 tracker 和 sensor 角色,此模式將包含 LoRa 無線電。如果您想搭配手機應用程式使用設備,或正在使用沒有使用者按鈕的設備,請勿啟用此設定。 + 從您的私鑰生成並傳送給網狀網路中的其他節點,以供它們計算出共享密鑰。 用於與遠端設備交換密鑰。 被授權可對此節點發送管理訊息的公鑰。 設備處於受管理狀態,使用者無法變更任何設備設定。 @@ -594,6 +596,8 @@ 無視MQTT 允許轉發至 MQTT MQTT配置 + 已中斷連線 + 已連線 啟用MQTT服務器 地址 用戶名 @@ -853,6 +857,7 @@ BLE: %1$s WiFi: %1$s 無可用的 PAX 人流計量資料。 + mPWRD-OS 的 Wi-Fi 設定 藍牙裝置 連接裝置 超過速率限制,請稍後再嘗試。 @@ -1158,6 +1163,7 @@ 保留路由跳數 注意 裝置儲存空間與使用者介面(唯讀) + 主題 %1$s,語言 %2$s 未發現任何檔案。 連線 完成 @@ -1166,10 +1172,19 @@ 進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS 正在搜尋裝置… 找到裝置 + 搜尋網路 正在搜尋… + 正在套用 Wi-Fi 設定… 找不到網路 + 無法連接:%1$s + 無法搜尋到 Wi-Fi 網路:%1$s %1$d% 可用的網路 網路名稱(SSID) + 手動輸入或選擇一個網路 + Wi-Fi 已設定完成! + 無法套用 Wi-Fi 設定 + 顯示 Meshtastic + 離開 Meshtastic From dd74e501f302b5e178a1cdcc849b62b749877123 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:33:38 -0500 Subject: [PATCH 09/27] fix(ui): finish accessibility roles and action labels for clickable surfaces (#5170) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../composeResources/values/strings.xml | 8 +++ .../ui/components/DeviceListItem.kt | 18 +++++-- .../component/MessageActionsBottomSheet.kt | 50 ++++++++++++++++--- .../wifiprovision/ui/WifiProvisionScreen.kt | 9 +++- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 87268ecda..2c29ae3aa 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1276,4 +1276,12 @@ Filter Remove filter Show air quality legend + Show message status + Send reply + Copy message + Select message + Delete message + React with emoji + Select device + Select network diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index a4d8ecdd8..14f4dc42b 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -17,13 +17,13 @@ package org.meshtastic.feature.connections.ui.components import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement 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.selection.selectable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -41,11 +41,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_select_device import org.meshtastic.core.resources.add import org.meshtastic.core.resources.bluetooth import org.meshtastic.core.resources.network @@ -108,11 +112,19 @@ fun DeviceListItem( is DeviceListEntry.Mock -> stringResource(Res.string.add) } + val selectLabel = stringResource(Res.string.action_select_device) + val isSelected = connectionState is ConnectionState.Connected val clickableModifier = if (onDelete != null) { - Modifier.combinedClickable(onClick = onSelect, onLongClick = onDelete) + Modifier.semantics { selected = isSelected } + .combinedClickable( + onClickLabel = selectLabel, + role = Role.RadioButton, + onClick = onSelect, + onLongClick = onDelete, + ) } else { - Modifier.clickable(onClick = onSelect) + Modifier.selectable(selected = isSelected, role = Role.RadioButton, onClick = onSelect) } ListItem( diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index c4c99720c..5ffb5ea1d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -36,11 +36,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_copy_message +import org.meshtastic.core.resources.action_delete_message +import org.meshtastic.core.resources.action_react_with_emoji +import org.meshtastic.core.resources.action_select_message +import org.meshtastic.core.resources.action_send_reply +import org.meshtastic.core.resources.action_show_message_status import org.meshtastic.core.resources.copy import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.device_metrics_label_value @@ -55,6 +62,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Reply import org.meshtastic.core.ui.icon.SelectAll +@Suppress("LongMethod") @Composable fun MessageActionsContent( quickEmojis: List, @@ -83,20 +91,35 @@ fun MessageActionsContent( Text(stringResource(Res.string.device_metrics_label_value, title, statusText.orEmpty())) }, leadingContent = { MessageStatusIcon(status = status) }, - modifier = Modifier.clickable(onClick = onStatus), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_show_message_status), + role = Role.Button, + onClick = onStatus, + ), ) } ListItem( headlineContent = { Text(stringResource(Res.string.reply)) }, leadingContent = { Icon(MeshtasticIcons.Reply, contentDescription = stringResource(Res.string.reply)) }, - modifier = Modifier.clickable(onClick = onReply), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_send_reply), + role = Role.Button, + onClick = onReply, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.copy)) }, leadingContent = { Icon(MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy)) }, - modifier = Modifier.clickable(onClick = onCopy), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_copy_message), + role = Role.Button, + onClick = onCopy, + ), ) ListItem( @@ -104,13 +127,23 @@ fun MessageActionsContent( leadingContent = { Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select)) }, - modifier = Modifier.clickable(onClick = onSelect), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_select_message), + role = Role.Button, + onClick = onSelect, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.delete)) }, leadingContent = { Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete)) }, - modifier = Modifier.clickable(onClick = onDelete), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_delete_message), + role = Role.Button, + onClick = onDelete, + ), ) } } @@ -130,7 +163,12 @@ private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit, Modifier.size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onReact(emoji) }, + .clickable( + onClickLabel = stringResource(Res.string.action_react_with_emoji), + role = Role.Button, + ) { + onReact(emoji) + }, contentAlignment = Alignment.Center, ) { Text(text = emoji, style = MaterialTheme.typography.titleMedium) diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt index 015a4e08b..397710fea 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -87,6 +88,7 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_select_network import org.meshtastic.core.resources.apply import org.meshtastic.core.resources.back import org.meshtastic.core.resources.cancel @@ -489,7 +491,12 @@ internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () - } }, colors = ListItemDefaults.colors(containerColor = containerColor), - modifier = Modifier.clickable(onClick = onClick), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_select_network), + role = Role.Button, + onClick = onClick, + ), ) } From 10bc58d4178fbd421dc2bac19b37bf0c89c4817e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:36:32 -0500 Subject: [PATCH 10/27] chore(strings): remove 4 unused string resources (#5173) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/commonMain/composeResources/values/strings.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 2c29ae3aa..481a94b78 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -408,12 +408,9 @@ User Info New node notifications SNR - Signal-to-Noise Ratio, a measure used in communications to quantify the level of a desired signal to the level of background noise. In Meshtastic and other wireless systems, a higher SNR indicates a clearer signal that can enhance the reliability and quality of data transmission. RSSI - Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection. (Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500. Device Metrics - Node Map Position Last position update Environment Metrics @@ -460,7 +457,6 @@ 1M Max Min - Avg Expand chart Collapse chart Unknown Age From c866f60b59fc2678c348b333a0f52ae2deb39831 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:36:33 -0500 Subject: [PATCH 11/27] diag(r8): disable minify for release builds (animation-freeze diagnostic) (#5174) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AndroidApplicationConventionPlugin.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 38cc021a7..81729f5a0 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -41,8 +41,22 @@ class AndroidApplicationConventionPlugin : Plugin { buildTypes { getByName("release") { - isMinifyEnabled = true - isShrinkResources = true + // DIAGNOSTIC BUILD (internal.60): R8 is fully disabled to prove + // whether the Compose animation freeze observed since CMP 1.11.0-beta02 + // is caused by R8 (shrinking / consumer-rule -assumenosideeffects / + // resource shrinking) or by a CMP runtime bug independent of R8. + // + // - If animations work in this build → R8 is the cause; follow up with + // a narrower diag (weaken Compose -keep rules to allow class-merging, + // or toggle isShrinkResources only). + // - If animations remain frozen → R8 is innocent; the bug is in + // compose-multiplatform 1.11.0-beta02 itself. File upstream and + // downgrade / pin. + // + // REVERT once the diagnostic is concluded — release builds MUST ship + // with R8 enabled. + isMinifyEnabled = false + isShrinkResources = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), rootProject.file("config/proguard/shared-rules.pro"), From a273dc6623a43033df4c39d769e4c286337cbd39 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:07:54 -0500 Subject: [PATCH 12/27] Revert "diag(r8): disable minify for release builds (animation-freeze diagnostic)" (#5176) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AndroidApplicationConventionPlugin.kt | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 81729f5a0..38cc021a7 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -41,22 +41,8 @@ class AndroidApplicationConventionPlugin : Plugin { buildTypes { getByName("release") { - // DIAGNOSTIC BUILD (internal.60): R8 is fully disabled to prove - // whether the Compose animation freeze observed since CMP 1.11.0-beta02 - // is caused by R8 (shrinking / consumer-rule -assumenosideeffects / - // resource shrinking) or by a CMP runtime bug independent of R8. - // - // - If animations work in this build → R8 is the cause; follow up with - // a narrower diag (weaken Compose -keep rules to allow class-merging, - // or toggle isShrinkResources only). - // - If animations remain frozen → R8 is innocent; the bug is in - // compose-multiplatform 1.11.0-beta02 itself. File upstream and - // downgrade / pin. - // - // REVERT once the diagnostic is concluded — release builds MUST ship - // with R8 enabled. - isMinifyEnabled = false - isShrinkResources = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), rootProject.file("config/proguard/shared-rules.pro"), From 61d7f6fef3e08811e9b0b54b34699ce6873b4ed6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:46:59 -0500 Subject: [PATCH 13/27] fix(deps): pin androidx-compose runtime-tracing/ui-test to CMP version (#5179) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gradle/libs.versions.toml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 12ab9480c..fe96dc45e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,10 +35,11 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" compose-multiplatform-material3 = "1.11.0-alpha06" -# AndroidX Compose test/tracing artifacts share a version track with CMP but are resolved -# independently by Maven. Pinning them to their own ref prevents Renovate from bumping the -# CMP plugin version when a new AndroidX Compose pre-release appears. -androidx-compose = "1.11.0-rc01" +# `androidx-compose-material` (M2) is independent of CMP and pinned separately +# because some third-party libs (maps-compose-widgets, datadog) drag in +# unversioned material transitives. Test/tracing artifacts in the +# androidx.compose.{runtime,ui} groups MUST track CMP — use compose-multiplatform +# as their version ref, not a separate pin. androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" @@ -121,8 +122,8 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) -androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose" } -androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose" } # Required by Robolectric Compose tests (registers ComponentActivity) +androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity) # Compose Multiplatform compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" } From ef0e159abbb504a671f90ac601f0d27d850a6823 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:20:58 -0500 Subject: [PATCH 14/27] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5177) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-ar/strings.xml | 1 + .../composeResources/values-be/strings.xml | 1 + .../composeResources/values-bg/strings.xml | 6 +- .../composeResources/values-ca/strings.xml | 1 + .../composeResources/values-cs/strings.xml | 4 +- .../composeResources/values-de/strings.xml | 6 +- .../composeResources/values-el/strings.xml | 1 + .../composeResources/values-es/strings.xml | 4 +- .../composeResources/values-et/strings.xml | 8 +- .../composeResources/values-fi/strings.xml | 9 +- .../composeResources/values-fr/strings.xml | 182 +++++++++++++++++- .../composeResources/values-ga/strings.xml | 4 +- .../composeResources/values-gl/strings.xml | 1 + .../composeResources/values-he/strings.xml | 1 + .../composeResources/values-hr/strings.xml | 1 + .../composeResources/values-ht/strings.xml | 4 +- .../composeResources/values-hu/strings.xml | 4 +- .../composeResources/values-it/strings.xml | 4 +- .../composeResources/values-ja/strings.xml | 4 +- .../composeResources/values-ko/strings.xml | 4 +- .../composeResources/values-lt/strings.xml | 2 +- .../composeResources/values-nl/strings.xml | 4 +- .../composeResources/values-no/strings.xml | 4 +- .../composeResources/values-pl/strings.xml | 4 +- .../values-pt-rBR/strings.xml | 4 +- .../composeResources/values-pt/strings.xml | 4 +- .../composeResources/values-ro/strings.xml | 5 +- .../composeResources/values-ru/strings.xml | 6 +- .../composeResources/values-sk/strings.xml | 4 +- .../composeResources/values-sl/strings.xml | 4 +- .../composeResources/values-sq/strings.xml | 4 +- .../composeResources/values-sr/strings.xml | 4 +- .../composeResources/values-srp/strings.xml | 4 +- .../composeResources/values-sv/strings.xml | 5 +- .../composeResources/values-tr/strings.xml | 4 +- .../composeResources/values-uk/strings.xml | 4 +- .../values-zh-rCN/strings.xml | 5 +- .../values-zh-rTW/strings.xml | 6 +- .../android/fr-FR/changelogs/default.txt | 2 +- 39 files changed, 229 insertions(+), 100 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml index 0aabb2f37..2e4eaf53c 100644 --- a/core/resources/src/commonMain/composeResources/values-ar/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ar/strings.xml @@ -174,4 +174,5 @@ إعدادات بلوتوث + عربي diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml index 03216a10a..cb615de37 100644 --- a/core/resources/src/commonMain/composeResources/values-be/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml @@ -222,4 +222,5 @@ Сіні Зялёны Meshtastic + Фільтраваць diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index 0a1478bb2..cdf34f2d3 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -324,12 +324,9 @@ Публичният ключ не съвпада със записания ключ. Можете да премахнете възела и да го оставите да обмени ключове отново, но това може да показва по-сериозен проблем със сигурността. Свържете се с потребителя чрез друг надежден канал, за да определите дали промяната на ключа се дължи на фабрично нулиране или друго умишлено действие. Известия за нови възли SNR - Съотношение сигнал/шум, мярка, използвана в комуникациите за количествено определяне на нивото на желания сигнал спрямо нивото на фоновия шум. В Meshtastic и други безжични системи, по-високото съотношение сигнал/шум показва по-ясен сигнал, който може да подобри надеждността и качеството на предаване на данни. RSSI - Индикатор за силата на получения сигнал - измерване, използвано за определяне на нивото на получения сигнал, приемано от антената. По-високата стойност на RSSI обикновено показва по-силна и по-стабилна връзка. (Качество на въздуха в помещенията) относителна скала за IAQ, стойностите са измерени с Bosch BME680. Диапазон на стойностите 0–500. Метрики на устройството - Карта на възела Позиция Последна актуализация на позицията Показатели на околната среда @@ -369,7 +366,6 @@ Макс Мин - Ср Разгъване на диаграмата Свиване на диаграмата Неизвестна възраст @@ -971,4 +967,6 @@ Прилагането на конфигурацията за WiFi не е успешно Изход Meshtastic + Филтър + Изберете устройство diff --git a/core/resources/src/commonMain/composeResources/values-ca/strings.xml b/core/resources/src/commonMain/composeResources/values-ca/strings.xml index d2ee2550b..22b52e28e 100644 --- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml @@ -201,4 +201,5 @@ Meshtastic + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index 66e9d84d8..d3e0566ac 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -331,12 +331,9 @@ Informace o uživateli Oznámení o nových uzlech SNR - Poměr signálu k šumu (SNR) je veličina používaná k vyjádření poměru mezi úrovní požadovaného signálu a úrovní šumu na pozadí. V Meshtastic a dalších bezdrátových systémech vyšší hodnota SNR značí čistší signál, což může zvýšit spolehlivost a kvalitu přenosu dat. RSSI - Indikátor síly přijímaného signálu, měření, které se používá k určení hladiny výkonu přijímané anténou. Vyšší hodnota RSSI obvykle znamená silnější a stabilnější spojení. (Vnitřní kvalita ovzduší) relativní hodnota IAQ měřená Bosch BME680. Hodnota rozsahu 0–500. Metriky zařízení - Mapa uzlu Pozice Poslední aktualizace pozice Metriky prostředí @@ -971,4 +968,5 @@ Připojit Hotovo Meshtastic + Filtr diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 4d1161573..8e97b008f 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -383,12 +383,9 @@ Benutzerinfo Benachrichtigung neue Knoten SNR - Signal-Rausch-Verhältnis, ein in der Kommunikation verwendetes Maß, um den Pegel eines gewünschten Signals im Verhältnis zum Pegel des Hintergrundrauschens zu quantifizieren. Bei Meshtastic und anderen drahtlosen Systemen weist ein höheres SNR auf ein klareres Signal hin, das die Zuverlässigkeit und Qualität der Datenübertragung verbessern kann. RSSI - Indikator für die empfangene Signalstärke, eine Messung zur Bestimmung der von der Antenne empfangenen Leistungsstärke. Ein höherer RSSI-Wert weist im Allgemeinen auf eine stärkere und stabilere Verbindung hin. (Innenluftqualität) relativer IAQ-Wert gemessen von Bosch BME680. Gerätedaten - Standortkarte Knoten Standort Letzte Standortaktualisierung Umweltdaten @@ -435,7 +432,6 @@ 1 Monat Maximal Minimum - Durchschnitt Diagramm einblenden Diagramm ausblenden Alter unbekannt @@ -1211,4 +1207,6 @@ WLAN erfolgreich konfiguriert! WLAN Konfiguration konnte nicht angewendet werden Meshtastic + Filter + Gerät auswählen diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml index 6d691ec4d..8386ac2ea 100644 --- a/core/resources/src/commonMain/composeResources/values-el/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml @@ -201,4 +201,5 @@ Κόκκινο Μπλε Πράσινο + Φίλτρο diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index d6505500f..4c59aa547 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -300,13 +300,10 @@ Clave pública no coincide Notificaciones de nuevo nodo SNR - SNR: Ratio de señal a ruido, una medida utilizada en las comunicaciones para cuantificar el nivel de una señal deseada respecto al nivel del ruido de fondo. En Meshtastic y otros sistemas inalámbricos, un mayor SNR indica una señal más clara que puede mejorar la fiabilidad y la calidad de la transmisión de datos. RSSI - Indicador de Fuerza de Señal Recibida (RSSI en inglés), una medida utilizada para determinar el nivel de potencia que está siendo recibido por la antena. Un valor de RSSI más alto generalmente indica una conexión más fuerte y estable. (Calidad de Aire interior) escala relativa del valor IAQ como mediciones del sensor Bosch BME680. Rango de Valores 0 - 500. Métricas de Dispositivo - Mapa de Nodos Posición Última actualización Métricas de Entorno @@ -839,4 +836,5 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Conectar Hecho Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 9833e43a3..be6376d0c 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -383,12 +383,9 @@ Kasutaja teave Uue sõlme teade SNR - Signaali ja müra suhe (SNR) on mõõdik, mida kasutatakse soovitud signaali taseme ja taustamüra taseme vahelise suhte määramisel. Meshtastic ja teistes traadita süsteemides näitab kõrgem signaali ja müra suhe selgemat signaali, mis võib parandada andmeedastuse usaldusväärsust ja kvaliteeti. RSSI - Vastuvõetud signaali tugevuse indikaator (RSSI), mõõt mida kasutatakse antenni poolt vastuvõetava võimsustaseme määramiseks. Kõrgem RSSI väärtus näitab üldiselt tugevamat ja stabiilsemat ühendust. Siseõhu kvaliteet (IAQ) on suhtelise skaala väärtus, näiteks mõõtes Bosch BME680 abil. Väärtuste vahemik 0–500. Seadme mõõdikud - Sõlmede kaart Asukoht Viimase asukoha värskendus Keskkonnamõõdikud @@ -435,7 +432,6 @@ 1k Maksimaalselt Min - Keskm Laienda diagrammi Ahenda diagrammi Tundmatu vanus @@ -613,8 +609,11 @@ Keela MQTT Ok MQTTi MQTT sätted + Mitteaktiivne Ühendus katkenud + Ühendan… Ühendatud + Taas ühendan… MQTT lubatud Aadress Kasutajatunnus @@ -1214,4 +1213,5 @@ Näita Meshtastic Sule Kärgvõrgustik + Filtreeri diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index b7c861b5e..c3bc3dc9e 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -383,12 +383,9 @@ Käyttäjätiedot Uuden laitteen ilmoitukset SNR - Signaali-kohinasuhde (SNR) on mittari, jota käytetään viestinnässä halutun signaalin tason ja taustahälyn tason määrittämisessä. Meshtasticissa ja muissa langattomissa järjestelmissä korkeampi SNR tarkoittaa selkeämpää signaalia, joka voi parantaa tiedonsiirron luotettavuutta ja laatua. RSSI - Vastaanotetun signaalin voimakkuusindikaattori (RSSI) on mittari, jota käytetään määrittämään antennilla vastaanotetun signaalin voimakkuus. Korkeampi RSSI-arvo yleensä osoittaa vahvemman ja vakaamman yhteyden. Sisäilman laatu (IAQ) on suhteellinen asteikko, jota voidaan mitata mm. Bosch BME680 anturilla ja sen arvoväli on 0–500. Laitteen mittausloki - Laitekartta Sijainti Viimeisin sijainnin päivitys Ympäristöarvot @@ -435,7 +432,6 @@ 1 kk Kaikki Minimi - Keskiarvo Laajenna kaavio Pienennä kaavio Tuntematon ikä @@ -613,8 +609,11 @@ Ohita MQTT MQTT päällä MQTT asetukset + Passiivinen Ei yhdistetty + Yhdistetään… Yhdistetty + Yhdistetään uudelleen… MQTT käytössä Osoite Käyttäjänimi @@ -1215,4 +1214,6 @@ Näytä Meshtastic Lopeta Meshtastic + Suodatus + Valitse laite diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index f39208b94..b9c28e4cc 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -18,6 +18,7 @@ Meshtastic + Meshtastic %1$s Filtre Effacer le filtre de nœud Filtrer par @@ -40,9 +41,11 @@ Interne par Favoris Afficher uniquement les nœuds ignorés + Exclure MQTT Non reconnu En attente d'accusé de réception En file d'attente pour l'envoi + Délivré au nœud Inconnu Routage via chaîne SF++… Confirmé via chaîne SF++ @@ -119,7 +122,8 @@ Distance minimale en mètres pour considérer une diffusion de position intelligente. À quelle fréquence devrions-nous essayer d'obtenir une position GPS (<10sec le GPS est maintenu allumé). Champs optionnels à inclure dans les messages de position. Plus il y en a, plus le message est grand, plus cela augmentant le temps d'occupation du réseau et le risque de perte. - Sera en veille profonde autant que possible, pour les rôles traqueur et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. + Sera en veille profonde autant que possible, pour les rôles traceurs et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. + Généré à partir de votre clé publique et envoyé à d'autres nœuds sur le maillage pour leur permettre de calculer une clé secrète partagée. Utilisée pour créer une clé partagée avec un appareil distant. Clé publique autorisée à envoyer des messages d’administration à ce nœud. L'appareil est géré par un administrateur de maillage, l'utilisateur ne peut accéder à aucun des paramètres de l'appareil. @@ -163,18 +167,25 @@ Port : Connecté Connexions actuelles : - IP WiFi : + IP du Wifi : IP Ethernet : Connexion en cours Non connecté Aucun appareil sélectionné Périphérique inconnu + Aucun périphérique réseau trouvé + Pas de périphérique USB trouvé + USB + Mode Démo Connecté à la radio, mais en mode veille Mise à jour de l’application requise Vous devez mettre à jour cette application sur l'app store (ou Github). Il est trop vieux pour dialoguer avec le micrologiciel de la radio. Veuillez lire nos docs sur ce sujet. Aucun (désactivé) Notifications de service Remerciements + Bibliothèques Open Source + Meshtastic est construit avec les bibliothèques open source suivantes. Appuyez sur n'importe quelle bibliothèque pour voir sa licence. + %1$d Bibliothèques Cette URL de canal est invalide et ne peut pas être utilisée Panneau de débogage Contenu décodé : @@ -207,7 +218,21 @@ Correspondre à tout | N'importe quel Cela supprimera tous les paquets de journaux et les entrées de la base de données de votre appareil - c'est une réinitialisation complète, et est permanent. Effacer + Rechercher des émojis... + Plus d'actions Canal + %1$s: %2$s + Message de %1$s: %2$s + Entête + Élément %1$d + Pied de page + Exporter le paquet de données TAK + Point + Texte + Jauge + Dégradé + Ceci est un composable personnalisé + Avec plusieurs lignes et styles Statut d'envoi du message Nouveaux messages au-dessous Notifications de message @@ -228,10 +253,15 @@ Rétablir les valeurs par défaut Appliquer Thème + Contraste Clair Sombre Valeur par défaut du système Choisir un thème + Niveau de contraste + Standard + Milieu + Haut Fournir l'emplacement au maillage Encodage compact pour Cyrillique @@ -275,6 +305,7 @@ Message direct Reconfiguration de NodeDB Réception confirmée par le destinataire + Votre appareil peut se déconnecter et redémarrer lorsque les paramètres sont appliqués. Erreur Une erreur inconnue s'est produite Ignorer @@ -317,6 +348,8 @@ Actuellement : Toujours muet Non muet + Muet pour %1$d jours, %2$s heures + Muet pour %1$s heures Désactiver les notifications pour '%1$s' ? Réactiver les notifications pour '%1$s' ? Remplacer @@ -326,7 +359,10 @@ Batterie UtilCanal UtilAir + %1$s / %2$s%% + %1$s: %2$s V %1$s + %1$s: %2$s Temp Hum Temp sol @@ -347,12 +383,9 @@ Infos utilisateur Notifikasyon nouvo nœud SNR - Signal-to-Noise Ratio, une mesure utilisée dans les communications pour quantifier le niveau du signal par rapport au niveau du bruit de fond. Dans les systèmes Meshtastic et autres systèmes sans fil, un SNR plus élevé indique un signal plus clair qui peut améliorer la fiabilité et la qualité de la transmission de données. RSSI - Indicateur de force du signal reçu, une mesure utilisée pour déterminer le niveau de puissance reçu par l'antenne. Une valeur RSSI plus élevée indique généralement une connexion plus forte et plus stable. (Qualité de l'air intérieur) valeur de l'échelle relative IAQ mesurée par Bosch BME680. Plage de valeur 0–500. Métriques de l’appareil - Carte historique des positions Position Dernière mise à jour de position Métriques d'environnement @@ -381,13 +414,26 @@ Durée : %1$s s Route aller :\n\n Route retour :\n\n + Saut vers l'avant + Saut vers l'arrière + Aller/Retour Pas de réponse + Charge 1 m + Charge 5m + Charge 15 m + Moyenne de charge du système d'une minute + Moyenne de charge du système de cinq minutes + Moyenne de charge du système de 15 minutes + Mémoire système disponible en octets 1H 24H 1S 2S 1M Max + Min + Agrandir le graphique + Réduire le graphique Age inconnu Copier Caractère d'appel ! @@ -401,11 +447,17 @@ Canal 1 Canal 2 Canal 3 + Canal 4 + Canal 5 + Canal 6 + Canal 7 + Canal 8 Actif Tension Êtes-vous sûr ? Documentation du rôle de l'appareil et le billet de blog sur comment Choisir le rôle de l'appareil approprié.]]> Je sais ce que je fais. + La batterie du nœud %1$s est faible (%2$d%) Notifications de batterie faible Batterie faible : %1$s Notifications de batterie faible (nœuds favoris) @@ -531,6 +583,9 @@ Durée de sortie (en millisecondes) Durée de répétition de la sortie (secondes) Sonnerie + Sonnerie importée + Le fichier est vide + Erreur d'importation : %1$s Lancer Utiliser l'I2S comme buzzer LoRa @@ -554,8 +609,11 @@ Ignorer MQTT Transmission des paquets vers MQTT Configuration MQTT + Inactif Déconnecté + Connexion… Connecté + Reconnexion… MQTT activé Adresse Nom d'utilisateur @@ -627,6 +685,8 @@ Série activée Écho activé Vitesse de transmission série + RX + Tx Délai d'expiration Mode série Outrepasser le port série de la console @@ -661,8 +721,15 @@ Distance Lux Vent + Vitesse du vent + Rafales de vent + Vent à la traîne + Direction du vent + Pluie (1h) + Pluie (24h) Poids Radiation + Températeur 1-Wire Qualité de l'air intérieur (IAQ) URL @@ -679,6 +746,7 @@ Horodatage En-tête Vitesse + %1$d Km/h Sats Alt Fréq @@ -744,6 +812,11 @@ Afficher les points de repère Afficher les cercles de précision Notification client + Vérification de la clé + Requête de vérification de clé + Vérification de la clé terminée + Clé publique dupliquée détectée + Clé de chiffrement faible détectée Clés compromises détectées, sélectionnez OK pour régénérer. Régénérer la clé privée Êtes-vous sûr de vouloir régénérer votre clé privée ?\n\nLes nœuds qui peuvent avoir précédemment échangé des clés avec ce nœud devront supprimer ce nœud et ré-échanger des clés afin de reprendre une communication sécurisée. @@ -796,7 +869,14 @@ Composer un message Métriques de PAX PAX + PAX : %1$d + B:%1$d + W :%1$d + PAX : %1$s + BLE: %1$s + Wi-Fi : %1$s Aucune métrique PAX disponible. + Approvisionnement Wi-Fi pour mPWRD-OS Appareils Bluetooth Périphérique connecté Limite de débit dépassée. Veuillez réessayer plus tard. @@ -851,6 +931,8 @@ Terrain Hybride Gérer les calques de la carte + Les calques personnalisés prennent en charge les fichiers .kml, .kmz ou GeoJSON. + Aucun calque personnalisé chargé. Ajouter un calque Afficher le calque Supprimer le calque @@ -858,6 +940,10 @@ Nœuds à cet emplacement Type de carte sélectionné Gérer les sources de tuiles personnalisées + Ajouter un réseau de tuile personnalisée + Aucune source de tuiles personnalisées trouvée. + Modifier le réseau de tuile personnalisée + Supprimer le réseau de tuile personnalisée Le nom ne peut pas être vide. Le nom du fournisseur existe déjà. URL ne peut être vide. @@ -951,6 +1037,7 @@ Notes de Version Une erreur inconnue s'est produite Les informations de l'utilisateur du nœud sont manquantes. + Batterie trop faible (%1$d%). Veuillez charger votre appareil avant de mettre à jour. Impossible de récupérer le fichier firmware. Échec de la mise à jour USB Intégrité (hash) du firmware rejetée. Veuillez réessayer ou mettre à jours l'appareil via USB. @@ -1027,8 +1114,10 @@ Configuration Gérer à distance sans fil les paramètres et les canaux de votre appareil. Sélection du style de carte + Batterie : %1$d% Nœuds : %1$d en ligne / %2$d au total Temps de disponibilité : %1$s + ChUtil: %1$s% | AirTX: %2$s% Trafic : TX %1$d / RX %2$d (D: %3$d) Relais : %1$d (annulé: %2$d) Diagnostiques : %1$s @@ -1042,11 +1131,94 @@ Actualiser Mis à jour + Ajouter une couche de réseau + Fichier local MBTiles + Ajouter un fichier local MBTiles + TAK (ATAK) + Configuration TAK + Activer le serveur TAK local + Démarre un serveur TCP sur le port 8089 pour les connexions ATAK + Couleur de l'équipe + Rôle Membre + Non spécifié + Blanc + Jaune + Orange + Magenta Rouge + Marron + Pourpre + Bleu foncé Bleu + Cyan + Turquoise Vert + Vert Foncé + Marron + Non spécifié + Membre de l'équipe + Chef d'équipe + Quartier général + Tireur d'élite + Medic + Observateur de transfert + Opérateur de radio téléphonie + Doggo (K9) + Gestion du trafic + Configuration de la gestion du trafic Module activé + Déduplication de Position + Précision de position (octets) + Intervalle de position min (secs) + Réponse directe de NodeInfo + Max de saut pour une réponse directe + Limitation de débit + Fenêtre de limitation de taux (secs) + Paquets maximum dans la fenêtre + Ignorer les paquets inconnus + Seuil de paquets inconnu + Télémétrie locale uniquement (Relays) + Position locale uniquement (Relays) + Conserver les sauts du Routeur + Note + Stockage de l'appareil & UI (lecture seule) + Thème %1$s, Langue %2$s + Fichiers disponibles (%1$d ) : + - %1$s (%2$d octets) + Aucun fichier affiché. Connecter Terminé + Approvisionnement Wi-Fi pour mPWRD-OS + Fournissez les identifiants Wi-Fi à votre appareil mPWRD-OS via Bluetooth. + En savoir plus sur le projet mPWRD-OS\nhttps://github.com/mPWRD-OS + Recherche de l'appareil + Appareil détecté + Prêt à rechercher des réseaux WiFi. + Rechercher des réseaux + Recherche… + Application de la configuration WiFi… + Aucun réseau trouvé + Impossible de se connecter : %1$s + Échec de la recherche des réseaux WiFi : %1$s + %1$d% + Réseaux disponibles + Nom du réseau (SSID) + Saisir ou sélectionnez un réseau + WiFi configuré avec succès ! + Impossible d'appliquer la configuration WiFi + Meshtastic application de bureau + Afficher Meshtastic + Quitter Meshtastic + Exporter le paquet de données TAK + Filtre + Supprimer le filtre + Afficher le statut du message + Envoyer une réponse + Copier le message + Sélectionner le message + Supprimer le message + Réagir avec un emoji + Sélectionner l'appareil + Sélectionner le réseau diff --git a/core/resources/src/commonMain/composeResources/values-ga/strings.xml b/core/resources/src/commonMain/composeResources/values-ga/strings.xml index 7ddebc824..baabf41d0 100644 --- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml @@ -190,10 +190,7 @@ Cóid Poiblí Eochair Mícomhoiriúnacht na heochrach phoiblí Fógartha faoi na nodes nua - Ráta Sigineal go Torann, tomhas a úsáidtear i gcomhfhreagras chun an leibhéal de shígnéil inmhianaithe agus torann cúlra a mheas. I Meshtastic agus i gcórais gan sreang eile, ciallaíonn SNR níos airde go bhfuil sígneál níos soiléire ann agus ábalta méadú ar chreideamh agus cáilíocht an tarchur sonraí. - Táscaire Cumhachta Athnuachana Aithint an Aoise, tomhas a úsáidtear chun leibhéal cumhachta atá faighte ag an antsnáithe a mheas. Léiríonn RSSI níos airde gnóthachtáil níos laige atá i gceangal seasmhach agus níos láidre. (Cáilíocht Aeir Inmheánach) scála ábhartha den luach QAÍ a thomhas ag Bosch BME680. Scála Luach 0–500. - Léarscáil an Node Rialachas Rialú iargúlta Go dona @@ -230,4 +227,5 @@ + Scagaire diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml index 0dc8fd892..dc751d2e9 100644 --- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml @@ -165,4 +165,5 @@ + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml index 7239680f1..502d64056 100644 --- a/core/resources/src/commonMain/composeResources/values-he/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml @@ -148,4 +148,5 @@ + פילטר diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml index 6753d3559..114c3ed9a 100644 --- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml @@ -169,4 +169,5 @@ Crveno Meshtastic + Filtriraj diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml index 9c2b3beca..60e00d491 100644 --- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml @@ -186,10 +186,7 @@ Chifreman Kle Piblik Pa matche kle piblik Notifikasyon nouvo nœud - Rapò Siynal sou Bri, yon mezi ki itilize nan kominikasyon pou mezire nivo siynal vle a kont nivo bri ki nan anviwònman an. Nan Meshtastic ak lòt sistèm san fil, yon SNR pi wo endike yon siynal pi klè ki ka amelyore fyab ak kalite transmisyon done. - Endikatè Fòs Siynal Resevwa, yon mezi ki itilize pou detèmine nivo pouvwa siynal ki resevwa pa antèn nan. Yon RSSI pi wo jeneralman endike yon koneksyon pi fò ak plis estab. (Kalite Lèy Entèryè) echèl relatif valè IAQ jan li mezire pa Bosch BME680. Ranje valè 0–500. - Kat Nœud Administrasyon Administrasyon Remote Move @@ -218,4 +215,5 @@ + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml index b0270a8ab..33b795a7f 100644 --- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml @@ -316,12 +316,9 @@ Publikus kulcs nem egyezik Új állomás értesítések SNR - Jel–zaj arány (SNR): a kommunikációban a kívánt jel szintjének és a háttérzaj szintjének aránya. A Meshtastic és más vezeték nélküli rendszerek esetében a magasabb SNR tisztább jelet jelent, ami javítja az adatátvitel megbízhatóságát és minőségét. RSSI - Vett jelerősség-mutató (RSSI): az antenna által vett jel teljesítményszintjének mérése. A magasabb RSSI általában erősebb, stabilabb kapcsolatot jelez. (Beltéri levegőminőség) relatív IAQ érték a Bosch BME680 szenzor alapján. Értéktartomány: 0–500. Eszközmetrikák - Állomás Térkép Pozíció Utolsó pozíciófrissítés Környezeti metrikák @@ -853,4 +850,5 @@ Zöld Csatlakozás Meshtastic + Filter diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index 903b098d7..baa0e0947 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -355,12 +355,9 @@ Informazioni Utente Notifiche di nuovi nodi SNR - Rapporto segnale-rumore (Signal-to-Noise Ratio), una misura utilizzata nelle comunicazioni per quantificare il livello di un segnale desiderato rispetto al livello di rumore di fondo. In Meshtastic e in altri sistemi wireless, un SNR più elevato indica un segnale più chiaro che può migliorare l'affidabilità e la qualità della trasmissione dei dati. RSSI - Indicatore di forza del segnale ricevuto (Received Signal Strength Indicator), una misura utilizzata per determinare il livello di potenza ricevuto dall'antenna. Un valore RSSI più elevato indica generalmente una connessione più forte e più stabile. (Qualità dell'aria interna) scala relativa del valore della qualità dell'aria indoor, misurato da Bosch BME680. Valore Intervallo 0–500. Metriche Dispositivo - Mappa Dei Nodi Posizione Aggiornamento ultima posizione Metriche Ambientali @@ -963,4 +960,5 @@ Connetti Fatto Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml index 943ea2d90..64aa0fe05 100644 --- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml @@ -275,11 +275,8 @@ 公開キーが一致しません 新しいノードの通知 SN比 - 信号対ノイズ比(SN比)は、通信において、目的の信号のレベルを背景ノイズのレベルに対して定量化するために使用される尺度です。Meshtasticや他の無線システムでは、SN比が高いほど信号が鮮明であることを示し、データ伝送の信頼性と品質を向上させることができます。 RSSI - 受信信号強度インジケーター(RSSI)は、アンテナで受信している電力レベルを測定するための指標です。一般的にRSSI値が高いほど、より強力で安定した接続を示します。 (屋内空気品質) 相対スケールIAQ値は、ボッシュBME680によって測定されます。 値の範囲は 0-500。 - ノードマップ 位置 管理 リモート管理 @@ -655,4 +652,5 @@ モジュール有効 接続 Meshtastic + 絞り込み diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml index bc8f6bb3f..914446a60 100644 --- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml @@ -213,11 +213,8 @@ 공개 키가 일치하지 않습니다 새로운 노드 알림 SNR - 통신에서 원하는 신호의 수준을 배경 잡음의 수준과 비교하여 정량화하는 데 사용되는 신호 대 잡음비 Signal-to-Noise Ratio, SNR는 Meshtastic와 같은 무선 시스템에서 SNR이 높을수록 더 선명한 신호를 나타내어 데이터 전송의 안정성과 품질을 향상시킬 수 있습니다. RSSI - 수신 신호 강도 지표 Received Signal Strength Indicator, RSSI는 안테나가 수신하는 신호의 전력 수준을 측정하는 데 사용되는 지표입니다. RSSI 값이 높을수록 일반적으로 더 강력하고 안정적인 연결을 나타냅니다. (실내공기질) Bosch BME680으로 측정한 상대적 척도 IAQ 값. 범위 0–500. - 노드 지도 위치 최근 위치 업데이트 관리 @@ -542,4 +539,5 @@ 초록 연결 Meshtastic + 필터 diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml index 99f7fd3cf..33f5e4d59 100644 --- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml @@ -190,7 +190,6 @@ Naujo įtaiso pranešimas SNR RSSI - Įtaisų žemėlapis Administravimas Nuotolinis administravimas Silpnas @@ -238,4 +237,5 @@ Raudona + Filtras diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml index 4ef65fa1c..b6972b6ec 100644 --- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml @@ -201,11 +201,8 @@ Publieke sleutel komt niet overeen Nieuwe node meldingen SNR - Signal-to-Noise Ratio, een meeting die wordt gebruikt in de communicatie om het niveau van een gewenst signaal tegenover achtergrondlawaai te kwantificeren. In Meshtastische en andere draadloze systemen geeft een hoger SNR een zuiverder signaal aan dat de betrouwbaarheid en kwaliteit van de gegevensoverdracht kan verbeteren. RSSI - Ontvangen Signal Sterkte Indicator, een meting gebruikt om het stroomniveau te bepalen dat de antenne ontvangt. Een hogere RSSI-waarde geeft een sterkere en stabielere verbinding aan. (Binnenluchtkwaliteit) relatieve schaal IAQ waarde gemeten door Bosch BME680. Waarde tussen 0 en 500. - Node Kaart Positie Beheer Extern beheer @@ -418,4 +415,5 @@ Blauw Groen Verbinding maken + Filter diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml index d539af4f1..cd00c43e2 100644 --- a/core/resources/src/commonMain/composeResources/values-no/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml @@ -194,11 +194,8 @@ Direktemeldinger bruker den nye offentlige nøkkelinfrastrukturen for kryptering. Krever firmware versjon 2.5 eller høyere. Varsel om nye noder SNR - Signal-to-Noise Ratio, et mål som brukes i kommunikasjon for å sette nivået av et ønsket signal til bakgrunnstrøynivået. I Meshtastic og andre trådløse systemer tyder et høyere SNR på et klarere signal som kan forbedre påliteligheten og kvaliteten på dataoverføringen. RSSI - \"Received Signal Strength Indicator\", en måling som brukes til å bestemme strømnivået som mottas av antennen. Høyere RSSI verdi indikerer generelt en sterkere og mer stabil forbindelse. (Innendørs luftkvalitet) relativ skala IAQ-verdi målt ved Bosch BME680. Verdi 0–500. - Nodekart Administrasjon Fjernadministrasjon Dårlig @@ -241,4 +238,5 @@ + Filter diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 32055e52a..7c9b3433b 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -333,12 +333,9 @@ Informacje o użytkowniku Powiadomienia o nowych węzłach SNR: - Współczynnik sygnału do szumu (Signal-to-Noise Ratio) - miara stosowana w komunikacji do określania poziomu pożądanego sygnału w stosunku do poziomu szumu tła. W Meshtastic i innych systemach bezprzewodowych wyższy współczynnik SNR oznacza czystszy sygnał, który może zwiększyć niezawodność i jakość transmisji danych. RSSI: - Received Signal Strength Indicator - miara używana do określenia poziomu mocy odbieranej przez antenę. Wyższa wartość RSSI zazwyczaj oznacza silniejsze i bardziej stabilne połączenie. Jakość powietrza w pomieszczeniach (Indoor Air Quality) - wartość względna w skali IAQ mierzona czujnikiem BME680. Zakres wartości: 0–500. Metryka urządzenia - Ślad na mapie Pozycjonowanie Ostatnia aktualizacja lokalizacji Metryki środowiskowe @@ -752,4 +749,5 @@ Połącz Wykonano Meshtastic + Filtr diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml index dafb3b034..ac97b091c 100644 --- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -230,11 +230,8 @@ Chave pública não confere Novas notificações de nó SNR - Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado para o nível de ruído de fundo. Na Meshtastic e outros sistemas sem fios, uma SNR maior indica um sinal mais claro que pode melhorar a confiabilidade e a qualidade da transmissão de dados. RSSI - Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de potência que está sendo recebida pela antena. Um valor maior de RSSI geralmente indica uma conexão mais forte e mais estável. (Qualidade do ar interior) valor relativo da escala IAQ medido pelo Bosch BME680. Intervalo de Valor de 0–500. - Mapa do nó Posição Atualização da última posição Administração @@ -668,4 +665,5 @@ Verde Concluído Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml index 732a71dca..a00bce554 100644 --- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml @@ -219,11 +219,8 @@ Incompatibilidade de chave pública Notificações de novos nodes SNR - Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado com o nível de ruído de fundo. Em Meshtastic e outros sistemas sem fio. Quanto mais alta for a relação sinal-ruído, menor é o efeito do ruído de fundo sobre a deteção ou medição do sinal. RSSI - Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de energia que está a ser recebido pela antena. Um valor mais elevado de RSSI geralmente indica uma conexão mais forte e mais estável. (Qualidade do ar interior) valor relativo da escala IAQ conforme medida por Bosch BME680. Entre 0–500. - Mapa de nodes Posição Administração Administração Remota @@ -518,4 +515,5 @@ Verde Ligar Nome do nó de alternativo + Filtrar diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index aac8b4e16..f9787ba93 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -376,12 +376,9 @@ Info utilizator Notificări noduri noi SNR - Raportul semnal-zgomot (Signal-to-Noise Ratio), o măsură utilizată în comunicații pentru a cuantifica nivelul unui semnal dorit în raport cu nivelul zgomotului de fond. În Meshtastic și în alte sisteme wireless, un SNR mai mare indică un semnal mai clar, care poate îmbunătăți fiabilitatea și calitatea transmiterii datelor. RSSI - Indicatorul intensității semnalului recepționat (Received Signal Strength Indicator), o măsurătoare utilizată pentru a determina nivelul de putere recepționat de antenă. O valoare RSSI mai mare indică, în general, o conexiune mai puternică și mai stabilă. (Calitatea aerului interior) valoarea IAQ pe o scară relativă, măsurată cu Bosch BME680. Intervalul valorilor: 0–500. Valori dispozitiv - Harta nodurilor Poziție Ultima actualizare a poziției Indicatori de mediu @@ -424,7 +421,6 @@ 1W 2W Maxim - Medie Extindeți graficul Restrânge graficul Vârstă necunoscută @@ -1171,4 +1167,5 @@ WiFi configurat cu succes! Nu s-a reușit aplicarea configurației Wi-Fi Meshtastic + Filtru diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index cf0b1d421..cabfcd63f 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -389,12 +389,9 @@ Пользовательская информация Уведомления о новых нодах Сигнал/шум - Соотношение сигнал/шум, мера, используемая в коммуникациях для количественной оценки уровня желаемого сигнала по отношению к уровню фонового шума. В Meshtastic и других беспроводных системах более высокий SNR указывает на более четкий сигнал, который может повысить надежность и качество передачи данных. RSSI - Индикатор уровня принимаемого сигнала, измерение, используемое для определения уровня мощности, принимаемой антенной. Более высокое значение RSSI обычно указывает на более сильное и стабильное соединение. (Качество воздуха в помещении) Относительная шкала IAQ, измеренная Bosch BME680. Диапазон значений 0–500. Интервал передачи - Карта нод Местоположение Обновление последнего местоположения Метрики окружения @@ -443,7 +440,6 @@ Макс Мин - Сред Развернуть диаграмму Свернуть диаграмму Неизвестный возраст @@ -1227,4 +1223,6 @@ Wi-Fi успешно настроен! Не удалось применить настройку Wi-Fi Meshtastic + Фильтр + Выберите устройство diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml index c6dd2ee2d..6beec1a74 100644 --- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml @@ -257,11 +257,8 @@ Nezhoda verejného kľúča Notifikácie nových uzlov SNR - Pomer signálu od šumu (SNR), miera používaná v komunikácii na kvantifikáciu úrovne požadovaného signálu k úrovni hluku pozadia. V Meshtastic a iných bezdrôtových systémoch znamená vyšší SNR jasnejší signál, ktorý môže zvýšiť spoľahlivosť a kvalitu prenosu údajov. RSSI - Indikátor sily prijímaného signálu (RSSI), meranie používané na určenie úrovne výkonu prijatého skrz anténu. Vyššia hodnota RSSI vo všeobecnosti znamená silnejšie a stabilnejšie pripojenie. (Kvalita vzduchu v interiéri) relatívna hodnota IAQ meraná prístrojom Bosch BME680. Rozsah hodnôt 0–500. - Mapa uzlov Pozícia Administrácia Administrácia na diaľku @@ -430,4 +427,5 @@ Modrá Zelená Meshtastic + Filter diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml index 3605549aa..bff8e6150 100644 --- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml @@ -196,11 +196,8 @@ Neujemanje javnega ključa Obvestila novih vozlišč SNR - Razmerje med signalom in šumom je merilo, ki se uporablja v komunikacijah za količinsko opredelitev ravni želenega signala glede na raven hrupa v ozadju. V Meshtastic in drugih brezžičnih sistemih višji SNR pomeni jasnejši signal, ki lahko poveča zanesljivost in kakovost prenosa podatkov. RSSI - Indikator moči sprejetega signala je meritev, ki se uporablja za določanje ravni moči, ki jo sprejema antena. Višja vrednost RSSI na splošno pomeni močnejšo in stabilnejšo povezavo. (Kakovost zraka v zaprtih prostorih) relativna vrednost IAQ na lestvici, izmerjena z Bosch BME680. Razpon vrednosti 0–500. - Zemljevid vozlišč Administracija Administracija na daljavo Slab @@ -245,4 +242,5 @@ + Filter diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml index ec38d179c..edfac59b0 100644 --- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml @@ -186,10 +186,7 @@ Kriptimi me Çelës Publik Përputhje e Gabuar e Çelësit Publik Njoftimet për nyje të reja - Raporti i Sinjalit në Zhurmë, një masë e përdorur në komunikime për të kuantifikuar nivelin e një sinjali të dëshiruar ndaj nivelit të zhurmës në background. Në Meshtastic dhe sisteme të tjera pa tel, një SNR më i lartë tregon një sinjal më të pastër që mund të rrisë besueshmërinë dhe cilësinë e transmetimit të të dhënave. - Indikatori i Fuqisë së Sinjalit të Marrë, një matje e përdorur për të përcaktuar nivelin e energjisë që po merret nga antena. Një vlerë më e lartë RSSI zakonisht tregon një lidhje më të fortë dhe më të qëndrueshme. (Cilësia e Ajrit të Brendshëm) shkalla relative e vlerës IAQ siç matet nga Bosch BME680. Intervali i Vlerave 0–500. - Harta e Nyjës Administratë Administratë e Largët I Keq @@ -219,4 +216,5 @@ + Filtrimi diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index 7c1eff713..a365fc888 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -241,12 +241,9 @@ Неусаглашеност јавних кључева Обавештење о новом чвору SNR - Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података. RSSI - Indikator jačine primljenog signala RSSI, merenje koje se koristi za određivanje nivoa snage koji antena prima. Viša vrednost RSSI generalno ukazuje na jaču i stabilniju vezu. (Kvalitet vazduha u zatvorenom prostoru) relativna skala vrednosti IAQ merena Bosch BME680. Raspon vrednosti 0–500. Метрика уређаја - Mapa čvorova Позиција Метрике сензора Administracija @@ -432,4 +429,5 @@ Блутут Напајано + Filter diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index 9e8c0ce9b..5bfbb0a84 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -241,12 +241,9 @@ Неусаглашеност јавних кључева Обавештења о новим чворовима SNR - Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података. RSSI - Индикатор јачине примљеног сигнала RSSI је мера која се користи за одређивање нивоа снаге која се прима преко антене. Виша вредност RSSI генерално указује на јачу и стабилнију везу. Индекс квалитета ваздуха (IAQ) као мера за одређивање квалитета ваздуха унутрашњости, мерен са Bosch BME680. Вредности се крећу у распону од 0 до 500. Метрика уређаја - Мапа чворова Позиција Метрике сензора Администрација @@ -432,4 +429,5 @@ Блутут Напајано + Филтер diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index d970a394a..7d81b8a8c 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -339,12 +339,9 @@ Användarinfo Ny nod avisering SNR - Signal-to-Noise Ratio, är ett mått som används inom kommunikation för att kvantifiera nivån av en önskad signal mot nivån av bakgrundsbrus. I Meshtastic och andra trådlösa system indikerar en högre SNR en tydligare signal som kan förbättra tillförlitligheten och kvaliteten på dataöverföringen. RSSI - Received Signal Strength Indicator, ett mått som används för att avgöra effektnivån som togs emot av antennen. Ett högre RSSI-värde indikerar generellt en starkare och stabilare anslutning. (Indoor Air Quality) relativ skala IAQ värdet mätt med Bosch BME600. Värdeintervall 0-500. Enhetens mätvärden - Nod karta Plats Senaste positionsuppdatering Miljövärden @@ -948,4 +945,6 @@ Anslut Klart Meshtastic + Filter + Välj enhet diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml index 5dd6adbfd..75a9e3a5d 100644 --- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml @@ -213,11 +213,8 @@ Genel Anahtar Uyuşmazlığı Yeni düğüm bildirimleri SNR - Sinyal-Gürültü Oranı, iletişimde istenen bir sinyalin seviyesini arka plan gürültüsü seviyesine mukayese ölçmek için kullanılan bir ölçüdür. Meshtastic ve diğer kablosuz sistemlerde, daha yüksek bir SNR, veri iletiminin güvenilirliğini ve kalitesini artırabilecek daha net bir sinyale işaret eder. RSSI - Alınan Sinyal Gücü Göstergesi, anten tarafından alınan güç seviyesini belirlemek için kullanılan bir ölçüdür. Daha yüksek bir RSSI değeri genellikle daha güçlü ve daha istikrarlı bir bağlantıya işaret eder. (İç Hava Kalitesi) Bosch BME680 tarafından ölçülen bağıl ölçekli IAQ değeri. Değer Aralığı 0–500. - Düğüm Haritası Konum Yönetim Uzaktan Yönetim @@ -549,4 +546,5 @@ Yeşil Bağlan Meshtastic + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index c99e8d45f..b2034aae1 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -277,9 +277,7 @@ Сповіщення про нові вузли SNR RSSI - Показник рівня потужності сигналу — вимірювання, що використовується для визначення рівня потужності, що приймається антеною. Вище значення RSSI зазвичай вказує на міцніше та стабільніше з'єднання. Показники пристрою - Мапа вузлів Місцезнаходження Показники довкілля Адміністрування @@ -724,4 +722,6 @@ Під’єднатися Готово Meshtastic + Фільтри + Оберіть пристрій diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml index b8a1a9f0c..992e58187 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -362,12 +362,9 @@ 用户信息 新节点通知 SNR - 信噪比(Signal-to-Noise Ratio, SNR)是一种用于通信领域的测量指标,用于量化目标信号与背景噪声的比例。在 Meshtastic 及其他无线系统中,较高的信噪比表示信号更加清晰,从而能够提升数据传输的可靠性和质量。 RSSI - 接收信号强度指示(Received Signal Strength Indicator, RSSI)是一种用于测量天线接收到的信号功率的指标。较高的 RSSI 值通常表示更强、更稳定的连接。 室内空气质量(Indoor Air Quality, IAQ):由 Bosch BME680 传感器测量的相对标尺 IAQ 值,取值范围为 0–500。 设备指标 - 节点地图 定位 最后位置更新 传感器指标 @@ -1117,4 +1114,6 @@ 连接 完成 Meshtastic + 搜索节点 + 选择设备 diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml index 950ffeba8..a6313dae7 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -373,12 +373,9 @@ 使用者資訊 新節點通知 SNR - 信噪比(SNR),用於通訊中量化所需信號與背景噪音水平的指標。在 Meshtastic 及其他無線系統中,信噪比越高表示信號越清晰,可以提高數據傳輸的可靠性和品質。 RSSI - 接收信號強度指示(RSSI)用於測量天線所接收到信號的功率強度。 RSSI 值越高通常代表連線越強且穩定。 (室內空氣品質) 相對尺度 IAQ 值,由 Bosch BME680 測量。值範圍 0–500。 裝置計量資料 - 節點地圖 位置 最後位置更新 環境計量資料 @@ -418,7 +415,6 @@ 1個月 最大值 最小 - 平均 展開圖表 收起圖表 未知年齡 @@ -1187,4 +1183,6 @@ 顯示 Meshtastic 離開 Meshtastic + 過濾器 + 選擇裝置 diff --git a/fastlane/metadata/android/fr-FR/changelogs/default.txt b/fastlane/metadata/android/fr-FR/changelogs/default.txt index 0553de284..a322da020 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/default.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/default.txt @@ -1 +1 @@ -For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +Pour des notes de version détaillées, veuillez visiter : https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file From 14e86b90f124478efda4f3cbe3670be53721da07 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:33:55 -0500 Subject: [PATCH 15/27] =?UTF-8?q?feat(mqtt):=20adopt=20mqttastic-client-km?= =?UTF-8?q?p=200.2.0=20=E2=80=94=20disconnect=20reasons=20+=20Test=20Conne?= =?UTF-8?q?ction=20(#5181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/manager/MqttManagerImpl.kt | 63 +++++++- .../core/model/MqttConnectionState.kt | 41 +++-- .../meshtastic/core/model/MqttProbeStatus.kt | 52 +++++++ .../network/repository/MQTTRepositoryImpl.kt | 37 +++-- .../repository/MQTTRepositoryImplTest.kt | 79 ++++++++-- .../meshtastic/core/repository/MqttManager.kt | 12 ++ .../composeResources/values/strings.xml | 12 ++ .../org/meshtastic/desktop/stub/NoopStubs.kt | 2 +- .../settings/radio/RadioConfigViewModel.kt | 36 +++++ .../radio/component/MQTTConfigItemList.kt | 142 ++++++++++++++++-- .../radio/RadioConfigViewModelTest.kt | 2 +- gradle/libs.versions.toml | 2 +- 12 files changed, 425 insertions(+), 55 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt 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" From 4257e7b7e4483b605497b97d29054b0affa977d1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:41:36 -0500 Subject: [PATCH 16/27] chore(deps): split androidx-compose version ref from CMP (#5183) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/renovate.json | 9 +++++++++ gradle/libs.versions.toml | 16 +++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index dda9390c3..1faa1a4ad 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -56,6 +56,15 @@ "changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}", "automerge": true }, + { + "description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)", + "groupName": "compose-multiplatform", + "matchPackageNames": [ + "/^org\\.jetbrains\\.compose/", + "androidx.compose.runtime:runtime-tracing", + "androidx.compose.ui:ui-test-manifest" + ] + }, { "description": "Restrict sensitive infrastructure to manual minor updates", "matchUpdateTypes": [ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91f2ea6b7..73286aff3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,11 +35,17 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" compose-multiplatform-material3 = "1.11.0-alpha06" +# `androidx-compose-bom-aligned` tracks androidx.compose.{runtime,ui} test/tracing +# artifacts that ship in lockstep with CMP. Kept as a separate version ref so Renovate +# can bump androidx releases (which often land first) without dragging the +# `org.jetbrains.compose:*` artifacts and Gradle plugin to a version JetBrains +# hasn't published yet (see PR #5180). Should normally match `compose-multiplatform`; +# AndroidCompose.kt's resolutionStrategy force-aligns these groups to the CMP version +# at resolution time regardless of the declared value here. +androidx-compose-bom-aligned = "1.11.0-beta02" # `androidx-compose-material` (M2) is independent of CMP and pinned separately # because some third-party libs (maps-compose-widgets, datadog) drag in -# unversioned material transitives. Test/tracing artifacts in the -# androidx.compose.{runtime,ui} groups MUST track CMP — use compose-multiplatform -# as their version ref, not a separate pin. +# unversioned material transitives. androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" @@ -122,8 +128,8 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) -androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" } -androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity) +androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-bom-aligned" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-bom-aligned" } # Required by Robolectric Compose tests (registers ComponentActivity) # Compose Multiplatform compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" } From 68a414b75bc95e9e3d168aa5b8f8f513a4db1678 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:00:34 -0500 Subject: [PATCH 17/27] chore(deps): update compose-multiplatform to v1.11.0-rc01 (#5184) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73286aff3..79436cd48 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ compose-multiplatform-material3 = "1.11.0-alpha06" # hasn't published yet (see PR #5180). Should normally match `compose-multiplatform`; # AndroidCompose.kt's resolutionStrategy force-aligns these groups to the CMP version # at resolution time regardless of the declared value here. -androidx-compose-bom-aligned = "1.11.0-beta02" +androidx-compose-bom-aligned = "1.11.0-rc01" # `androidx-compose-material` (M2) is independent of CMP and pinned separately # because some third-party libs (maps-compose-widgets, datadog) drag in # unversioned material transitives. From 84fe24467f98e637213823be284f3e04d943ee69 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:11:32 -0500 Subject: [PATCH 18/27] fix(widget): drive updates via debounced state observer (#5185) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../feature/widget/AndroidAppWidgetUpdater.kt | 36 ++++++++++++++++--- .../feature/widget/LocalStatsWidgetState.kt | 9 +---- .../widget/src/main/res/values/strings.xml | 20 +++++++++++ .../main/res/xml/widget_local_stats_info.xml | 1 + 4 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 feature/widget/src/main/res/values/strings.xml diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt index 415e0e11d..c6cef8aa3 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt @@ -17,22 +17,48 @@ package org.meshtastic.feature.widget import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.updateAll import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.repository.AppWidgetUpdater +private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L + @Single -class AndroidAppWidgetUpdater(private val context: Context) : AppWidgetUpdater { +class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) : + AppWidgetUpdater { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + init { + // Observe state changes and trigger a widget re-render whenever the data changes. + // Glance compositions are ephemeral — the widget cannot self-update via collectAsState() + // alone, so we must call updateAll() externally to drive re-renders. + @OptIn(FlowPreview::class) + scope.launch { + stateProvider.state + .debounce(WIDGET_UPDATE_DEBOUNCE_MS) + .distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) } + .collect { if (hasWidgetInstances()) updateAll() } + } + } + + private suspend fun hasWidgetInstances(): Boolean = + GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty() + override suspend fun updateAll() { - // Kickstart the widget composition. - // The widget internally uses collectAsState() and its own sampled StateFlow - // to drive updates automatically without excessive IPC and recreation. @Suppress("TooGenericExceptionCaught") try { LocalStatsWidget().updateAll(context) } catch (e: Exception) { - co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" } + Logger.e(e) { "Failed to update widgets" } } } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt index ee40bd60b..b8aca2664 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt @@ -76,8 +76,6 @@ data class LocalStatsWidgetUiState( val updateTimeMillis: Long = 0, ) -private const val WIDGET_SUBSCRIPTION_TIMEOUT_MS = 5_000L - @Single class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -100,12 +98,7 @@ class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepos .map { input -> mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode) } - .distinctUntilChanged() - .stateIn( - scope = scope, - started = SharingStarted.WhileSubscribed(WIDGET_SUBSCRIPTION_TIMEOUT_MS), - initialValue = LocalStatsWidgetUiState(), - ) + .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState()) private data class StateInput( val connectionState: ConnectionState, diff --git a/feature/widget/src/main/res/values/strings.xml b/feature/widget/src/main/res/values/strings.xml new file mode 100644 index 000000000..1e47c86ee --- /dev/null +++ b/feature/widget/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + Meshtastic + diff --git a/feature/widget/src/main/res/xml/widget_local_stats_info.xml b/feature/widget/src/main/res/xml/widget_local_stats_info.xml index da9863cd9..6dde1ea1e 100644 --- a/feature/widget/src/main/res/xml/widget_local_stats_info.xml +++ b/feature/widget/src/main/res/xml/widget_local_stats_info.xml @@ -16,6 +16,7 @@ ~ along with this program. If not, see . --> Date: Sat, 18 Apr 2026 07:09:22 -0500 Subject: [PATCH 19/27] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5186) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-bg/strings.xml | 2 + .../composeResources/values-de/strings.xml | 2 + .../composeResources/values-et/strings.xml | 12 +++++ .../composeResources/values-fi/strings.xml | 23 ++++++++++ .../composeResources/values-fr/strings.xml | 2 + .../composeResources/values-ru/strings.xml | 1 + .../composeResources/values-sv/strings.xml | 2 + .../composeResources/values-uk/strings.xml | 1 + .../values-zh-rCN/strings.xml | 1 + .../values-zh-rTW/strings.xml | 44 +++++++++++++++++++ 10 files changed, 90 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index cdf34f2d3..ebf726c1c 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -488,6 +488,8 @@ Конфигуриране на MQTT Прекъсната връзка Свързано + Тестване на връзката + Връзката е неуспешна MQTT е активиран Адрес Потребителско име diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 8e97b008f..866eb8666 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -611,6 +611,8 @@ MQTT Einstellungen Verbindung getrennt Verbunden + Verbindung testen + Verbindung fehlgeschlagen MQTT aktiviert Adresse Benutzername diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index be6376d0c..5cadd4b6b 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -1213,5 +1213,17 @@ Näita Meshtastic Sule Kärgvõrgustik + Ekspordi TAK andmepakett + Eemalda ajatsoon Filtreeri + Eemalda filter + Näita õhukvaliteedi ajalugu + Kuva sõnumi olek + Saada vastus + Kopeeri sõnum + Vali sõnum + Kustuta sõnum + Vasta emotikoniga + Vali seade + Vali võrk diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index c3bc3dc9e..f9da71dea 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -611,9 +611,21 @@ MQTT asetukset Passiivinen Ei yhdistetty + Yhteys katkaistu — %1$s Yhdistetään… Yhdistetty Yhdistetään uudelleen… + Yhdistetään uudelleen (yritys %1$d) — %2$s + Testaa yhteys + Tarkistetaan välityspalvelinta… + Yhteys onnistui. Välityspalvelin hyväksyi tunnistetiedot. + Yhteys onnistui (%1$s) + Välityspalvelin ei hyväksynyt: %1$s + Palvelinta ei löytynyt + Yhteyttä välityspalvelimeen ei saada (TCP) + TLS-yhteyden muodostus epäonnistui + Aikakatkaistu %1$d ms jälkeen + Yhdistäminen epäonnistui MQTT käytössä Osoite Käyttäjänimi @@ -1214,6 +1226,17 @@ Näytä Meshtastic Lopeta Meshtastic + Vie TAK-datapaketti + Tyhjennä aikavyöhyke Suodatus + Poista suodatin + Näytä ilmanlaadun selite + Näytä viestin tila + Lähetä vastaus + Kopioi viesti + Valitse viesti + Poista viesti + Reaktio emojin kanssa Valitse laite + Valitse verkko diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index b9c28e4cc..f4afeef5c 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -614,6 +614,8 @@ Connexion… Connecté Reconnexion… + Test de la connexion + Échec de la connexion MQTT activé Adresse Nom d'utilisateur diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index cabfcd63f..430d8b802 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -619,6 +619,7 @@ Настройка MQTT Отключено Подключено + Проверить соединение MQTT включен Адрес Имя пользователя diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index 7d81b8a8c..999213506 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -528,6 +528,8 @@ MQTT-konfiguration Frånkopplad Ansluten + Testa anslutningen + Anslutningen misslyckades MQTT är aktiverat Adress Användarnamn diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index b2034aae1..c9a86af43 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -401,6 +401,7 @@ Налаштування MQTT Відключено Під’єднано + Перевірка зʼєднання MQTT увімкнений Адреса Ім'я користувача diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml index 992e58187..7fff0db20 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -567,6 +567,7 @@ MQTT设置 已断开连接 已连接 + 连接测试 启用MQTT 地址 用户名 diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml index a6313dae7..20ee6c639 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -222,6 +222,11 @@ %1$s: %2$s 來自 %1$s 的訊息:%2$s 標頭 + 標尾 + 點形 + 文字 + 儀表板 + 梯度 這是一個一個一個可客製化的組合元件 還支援多行文字與多種樣式 訊息傳遞狀態 @@ -407,6 +412,12 @@ 回程跳數 來回跳數 無回應 + 1分鐘負載 + 5分鐘負載 + 15分鐘負載 + 1分鐘系統負載平均值 + 5分鐘系統負載平均值 + 15分鐘系統負載平均值 可用系統記憶體(位元組) 1小時 二十四小時 @@ -592,8 +603,23 @@ 無視MQTT 允許轉發至 MQTT MQTT配置 + 已停用 已中斷連線 + 已斷線 — %1$s + 正在連接… 已連線 + 重新連接中… + 重新連接中(第 %1$d 次嘗試) — %2$s + 測試連線 + 正在查詢 Broker… + 可供連線,Broker 已驗證並接受憑證。 + 可供連線(%1$s) + Broker 遭拒:%1$s + 找不到伺服器 + 無法連線至 Broker 中繼伺服器(TCP) + TLS 握手失敗 + 經過 %1$d 毫秒後逾時 + 測試失敗 啟用MQTT服務器 地址 用戶名 @@ -792,6 +818,9 @@ 顯示路徑 顯示定位精準度 客户端通知 + 金鑰驗證 + 金鑰驗證請求 + 金鑰驗證已完成 偵測到重複的公鑰 偵測到加密金鑰強度不足 偵測到金鑰已洩漏,點選確定後重新產生金鑰。 @@ -1160,6 +1189,8 @@ 注意 裝置儲存空間與使用者介面(唯讀) 主題 %1$s,語言 %2$s + 可使用檔案(%1$d): + - %1$s(%2$d 位元) 未發現任何檔案。 連線 完成 @@ -1168,6 +1199,7 @@ 進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS 正在搜尋裝置… 找到裝置 + 準備好掃描 Wi-Fi 網路了。 搜尋網路 正在搜尋… 正在套用 Wi-Fi 設定… @@ -1180,9 +1212,21 @@ 手動輸入或選擇一個網路 Wi-Fi 已設定完成! 無法套用 Wi-Fi 設定 + Meshtastic Desktop 顯示 Meshtastic 離開 Meshtastic + 匯出 TAK 資料封包 + 清除時區 過濾器 + 移除篩選條件 + 顯示空氣品質圖例 + 顯示訊息狀態 + 傳送回覆 + 複製訊息 + 選擇訊息 + 刪除訊息 + 使用表情符號回應 選擇裝置 + 選擇網路 From 2c1984ace5b852d5ab0df7facd4bf92734dc2f50 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:30:34 -0500 Subject: [PATCH 20/27] chore(deps): update fastlane to v2.233.0 (#5190) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 46 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index de497cc4a..cf6a1b9c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1213.0) - aws-sdk-core (3.242.0) + aws-partitions (1.1240.0) + aws-sdk-core (3.245.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,11 +17,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.121.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-kms (1.123.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.213.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-s3 (1.219.0) + aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -29,7 +29,7 @@ GEM babosa (1.0.4) base64 (0.2.0) benchmark (0.5.0) - bigdecimal (4.0.1) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -68,11 +68,11 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.0) - fastlane (2.232.2) + fastimage (2.4.1) + fastlane (2.233.0) CFPropertyList (>= 2.3, < 4.0.0) abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) @@ -92,7 +92,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -122,10 +122,9 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.95.0) + google-apis-androidpublisher_v3 (0.99.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -139,15 +138,15 @@ GEM google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.17.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.59.0) + google-apis-storage_v1 (0.61.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (2.1.1) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.5.0) - google-cloud-storage (1.58.0) + google-cloud-errors (1.6.0) + google-cloud-storage (1.59.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -169,13 +168,13 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.1) + json (2.19.4) jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.19.1) + multi_json (1.20.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) @@ -185,13 +184,13 @@ GEM os (1.1.4) ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.2) - rake (13.3.1) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) + retriable (3.4.1) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) @@ -205,7 +204,6 @@ GEM simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) From 9dd57725f2d2687d40226e870eaa0490ef7531fb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:31:11 -0500 Subject: [PATCH 21/27] chore(deps): update vico to v3.2.0-next.1 (#5191) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79436cd48..baf89fb1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,7 +78,7 @@ uri-kmp = "0.0.21" osmdroid-android = "6.1.20" spotless = "8.4.0" wire = "6.2.0" -vico = "3.1.0" +vico = "3.2.0-next.1" kable = "0.42.0" mqttastic = "0.2.0" jmdns = "3.6.3" From 99e7407a90a4e3267a01e2aefdf4b4bc2a6b3e12 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:07:52 -0500 Subject: [PATCH 22/27] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5189) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-de/strings.xml | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 866eb8666..4755515ad 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -609,9 +609,22 @@ MQTT ignorieren OK für MQTT MQTT Einstellungen + Inaktiv Verbindung getrennt + Verbindung getrennt - %1$s + Wird verbunden Verbunden + Erneut verbinden + Erneut verbinden (Versuch %1$d) - %2$s Verbindung testen + Broker prüfen. + Erreichbar. Broker akzeptierte Anmeldedaten. + Erreichbar (%1$s) + Broker abgelehnt: %1$s + Host nicht gefunden + Broker (TCP) nicht erreichbar + TLS Handshake fehlgeschlagen + Zeitüberschreitung nach %1$d ms Verbindung fehlgeschlagen MQTT aktiviert Adresse @@ -1208,7 +1221,21 @@ Netzwerk eingeben oder auswählen WLAN erfolgreich konfiguriert! WLAN Konfiguration konnte nicht angewendet werden + Meshtastic Desktop + Meshtastic anzeigen + Beenden Meshtastic + TAK Datenpaket exportieren + Zeitzone löschen Filter + Filter entfernen + Legende für Luftqualität anzeigen + Nachrichtenstatus anzeigen + Antwort senden + Nachricht kopieren + Nachricht auswählen + Nachricht löschen + Mit Emoji reagieren Gerät auswählen + Wählen Sie ein Netzwerk From 3322257cfddc72f4a1b5f3b59910e948bb37ce91 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 06:47:09 -0500 Subject: [PATCH 23/27] chore(deps): update plugin com.gradle.develocity to v4.4.1 (#5194) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- build-logic/settings.gradle.kts | 2 +- settings.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 2fa797c74..91b8ebce2 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -30,7 +30,7 @@ pluginManagement { } plugins { - id("com.gradle.develocity") version("4.4.0") + id("com.gradle.develocity") version("4.4.1") } dependencyResolutionManagement { diff --git a/settings.gradle.kts b/settings.gradle.kts index f9664baaa..445d1cfac 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -83,7 +83,7 @@ dependencyResolutionManagement { plugins { id("org.gradle.toolchains.foojay-resolver") version "1.0.0" - id("com.gradle.develocity") version("4.4.0") + id("com.gradle.develocity") version("4.4.1") id("com.gradle.common-custom-user-data-gradle-plugin") version "2.6.0" } From 2b47da3b61421476c0d853f83300186ff8e1c59d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 20 Apr 2026 07:40:08 -0500 Subject: [PATCH 24/27] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5193) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-et/strings.xml | 12 ++++++++ .../composeResources/values-ru/strings.xml | 28 +++++++++++++++++++ .../composeResources/values-sv/strings.xml | 2 ++ 3 files changed, 42 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 5cadd4b6b..c2e327629 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -611,9 +611,21 @@ MQTT sätted Mitteaktiivne Ühendus katkenud + Ühendus katkenud — %1$s Ühendan… Ühendatud Taas ühendan… + Ühendan uuesti (katse %1$d) — %2$s + Test ühendus + Kontrollin vahendajat… + Ühendus õnnestus. Vahendaja aktsepteeris kasutajateave. + Kättesaadav (%1$s) + Vahendaja lükkas tagasi: %1$s + Hosti ei leitud + Vahendajaga ei saa ühendust (TCP) + TLS ühendus ebaõnnestus + Ajaline katkestus peale %1$d ms + Ühendus ebaõnnestus MQTT lubatud Aadress Kasutajatunnus diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index 430d8b802..8d4590e82 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -617,9 +617,23 @@ Игнорировать MQTT ОК в MQTT Настройка MQTT + Неактивно Отключено + Отключено — %1$s + Подключение... Подключено + Переподключение... + Переподключение (попытка %1$d) — %2$s Проверить соединение + Проверяем брокер… + Доступно. Брокер принял учетные данные. + Доступно (%1$s) + Брокер отклонен: %1$s + Узел не найден + Не удается подключиться к брокеру (TCP) + Сбой TLS-рукопожатия + Тайм-аут после %1$d мс + Соединение не удалось MQTT включен Адрес Имя пользователя @@ -1223,7 +1237,21 @@ Введите или выберите сеть Wi-Fi успешно настроен! Не удалось применить настройку Wi-Fi + Meshtastic Desktop + Показать Meshtastic + Выход Meshtastic + Экспорт пакета данных TAK + Очистить часовой пояс Фильтр + Удалить фильтр + Показать легенду качества воздуха + Показать статус сообщения + Отправить ответ + Скопировать сообщение + Выбрать сообщение + Удалить сообщение + Отреагировать эмодзи Выберите устройство + Выбрать сеть diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index 999213506..59e19f1e5 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -43,6 +43,7 @@ Okänd Inväntar kvittens Kvittens köad + Levererad till nät Okänd Kvitterad Ingen rutt @@ -370,6 +371,7 @@ Varaktighet: %1$s s Rutt spårad mot destination:\n\n Rutten spårad tillbaka till oss:\n\n + Inget svar 1h 24T 1V From 7492a33cf8cc0b1f67489d540a6ea322466508e7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:59:20 -0500 Subject: [PATCH 25/27] Fix node-details remove action to preserve confirmation flow (#5192) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: James Rich Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../feature/node/detail/HandleNodeAction.kt | 5 +- .../node/detail/NodeDetailViewModel.kt | 5 +- .../node/detail/NodeManagementActions.kt | 7 +- .../node/detail/HandleNodeActionTest.kt | 90 +++++++++++++++++++ .../node/detail/NodeManagementActionsTest.kt | 20 +++++ 5 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt index 9ce025604..559582417 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt @@ -43,10 +43,7 @@ internal fun handleNodeAction( val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode) navigateToMessages(route) } - is NodeMenuAction.Remove -> { - viewModel.handleNodeMenuAction(menuAction) - onNavigateUp() - } + is NodeMenuAction.Remove -> viewModel.handleNodeMenuAction(menuAction, onNavigateUp) else -> viewModel.handleNodeMenuAction(menuAction) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 733cd858c..e891d8ae0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -89,9 +89,10 @@ class NodeDetailViewModel( } /** Dispatches high-level node management actions like removal, muting, or favoriting. */ - fun handleNodeMenuAction(action: NodeMenuAction) { + fun handleNodeMenuAction(action: NodeMenuAction, onAfterRemove: () -> Unit = {}) { when (action) { - is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node) + is NodeMenuAction.Remove -> + nodeManagementActions.requestRemoveNode(viewModelScope, action.node, onAfterRemove) is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node) is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node) is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index 436954201..9c021e666 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -50,11 +50,14 @@ constructor( private val radioController: RadioController, private val alertManager: AlertManager, ) { - open fun requestRemoveNode(scope: CoroutineScope, node: Node) { + open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) { alertManager.showAlert( titleRes = Res.string.remove, messageRes = Res.string.remove_node_text, - onConfirm = { removeNode(scope, node.num) }, + onConfirm = { + removeNode(scope, node.num) + onAfterRemove() + }, ) } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt new file mode 100644 index 000000000..6bca8822b --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt @@ -0,0 +1,90 @@ +/* + * 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.node.detail + +import androidx.lifecycle.SavedStateHandle +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.feature.node.component.NodeMenuAction +import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase +import org.meshtastic.feature.node.model.NodeDetailAction +import org.meshtastic.proto.User +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse + +@OptIn(ExperimentalCoroutinesApi::class) +class HandleNodeActionTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val nodeManagementActions: NodeManagementActions = mock() + private val nodeRequestActions: NodeRequestActions = mock() + private val serviceRepository: ServiceRepository = mock() + private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { getNodeDetailsUseCase(any()) } returns emptyFlow() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `remove action delegates to viewModel and does not navigate up immediately`() = runTest(testDispatcher) { + val node = Node(num = 1234, user = User(id = "!1234")) + every { nodeManagementActions.requestRemoveNode(any(), any(), any()) } returns Unit + val viewModel = createViewModel() + var navigateUpCalled = false + + handleNodeAction( + action = NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node)), + uiState = NodeDetailUiState(), + navigateToMessages = {}, + onNavigateUp = { navigateUpCalled = true }, + onNavigate = {}, + viewModel = viewModel, + ) + + verify { nodeManagementActions.requestRemoveNode(any(), node, any()) } + assertFalse(navigateUpCalled) + } + + private fun createViewModel() = NodeDetailViewModel( + savedStateHandle = SavedStateHandle(mapOf("destNum" to 1234)), + nodeManagementActions = nodeManagementActions, + nodeRequestActions = nodeRequestActions, + serviceRepository = serviceRepository, + getNodeDetailsUseCase = getNodeDetailsUseCase, + ) +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 89015c807..3212a313e 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -30,6 +30,7 @@ import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.proto.User import kotlin.test.Test +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class NodeManagementActionsTest { @@ -69,4 +70,23 @@ class NodeManagementActionsTest { ) } } + + @Test + fun requestRemoveNode_invokes_onAfterRemove_when_user_confirms() { + val realAlertManager = AlertManager() + val actionsWithRealAlert = + NodeManagementActions( + nodeRepository = nodeRepository, + serviceRepository = serviceRepository, + radioController = radioController, + alertManager = realAlertManager, + ) + val node = Node(num = 123, user = User(long_name = "Test Node")) + var afterRemoveCalled = false + + actionsWithRealAlert.requestRemoveNode(testScope, node) { afterRemoveCalled = true } + realAlertManager.currentAlert.value?.onConfirm?.invoke() + + assertTrue(afterRemoveCalled) + } } From a90cb2d89e136020a1465edc8281fbf9396270ac Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:32:58 -0500 Subject: [PATCH 26/27] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5195) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../src/commonMain/composeResources/values-bg/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index ebf726c1c..f69e137d9 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -486,9 +486,16 @@ Честотен слот Игнориране на MQTT Конфигуриране на MQTT + Неактивен Прекъсната връзка + Свързване… Свързано + Повторно свързване… + Повторно свързване (опит %1$d) — %2$s Тестване на връзката + Достъпен. Брокерът е приел идентификационните данни. + Достъпен (%1$s) + Хостът не е намерен Връзката е неуспешна MQTT е активиран Адрес @@ -971,4 +978,5 @@ Meshtastic Филтър Изберете устройство + Изберете мрежа From f21d8af9aeb29b3e67f5e3119f6cdecd2d003ad1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:34:16 -0500 Subject: [PATCH 27/27] fix(transport): improve BLE / TCP / USB reconnect and handshake resilience (#5196) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + .../kotlin/org/meshtastic/app/MainActivity.kt | 18 +++++++++ .../core/ble/BleExceptionClassifier.kt | 9 ++++- .../data/manager/MeshConnectionManagerImpl.kt | 18 ++++++--- .../network/radio/SerialRadioTransport.kt | 5 ++- .../repository/SerialConnectionImpl.kt | 5 +++ .../core/network/repository/UsbRepository.kt | 10 ++--- .../core/network/radio/BleRadioTransport.kt | 6 ++- .../core/network/radio/BleReconnectPolicy.kt | 20 ++++++++-- .../core/network/radio/StreamTransport.kt | 12 +++--- .../network/radio/BleRadioTransportTest.kt | 38 ++++++++++--------- .../core/network/radio/TcpRadioTransport.kt | 6 ++- .../core/network/SerialTransport.kt | 13 +++++-- .../AndroidGetDiscoveredDevicesUseCase.kt | 9 ++++- 14 files changed, 124 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 8447bc7f7..447d8a28e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ wireless-install.sh firebase-debug.log .agent_plans/ .agent_refs/ +.agent_artifacts/ diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 0864e55cd..628865010 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -59,6 +59,7 @@ import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.ui.MainScreen import org.meshtastic.core.barcode.rememberBarcodeScanner import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI +import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.nfc.NfcScannerEffect import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_invalid @@ -91,6 +92,8 @@ import org.meshtastic.feature.node.metrics.TracerouteMapScreen class MainActivity : ComponentActivity() { private val model: UIViewModel by viewModel() + private val usbRepository: UsbRepository by inject() + /** * Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers * itself as a LifecycleObserver in its init block. @@ -166,6 +169,16 @@ class MainActivity : ComponentActivity() { handleIntent(intent) } + override fun onResume() { + super.onResume() + // Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is + // resumed while a USB device is already attached (e.g. process restart, returning + // from another app), the manifest-declared attach intent may have already fired + // before UsbRepository was constructed. Re-poll deviceList here so the UI reflects + // reality without requiring the user to physically replug. + usbRepository.refreshState() + } + @Composable private fun AppCompositionLocals(content: @Composable () -> Unit) { CompositionLocalProvider( @@ -257,6 +270,11 @@ class MainActivity : ComponentActivity() { UsbManager.ACTION_USB_DEVICE_ATTACHED -> { Logger.d { "USB device attached" } + // Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared + // receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository + // never sees this event. Forward it explicitly so the serialDevices StateFlow + // refreshes and the device shows up in the Connect → Serial tab. + usbRepository.refreshState() showSettingsPage() } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt index 6f5180b60..d273a0b90 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt @@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException /** * Classification of a BLE-layer exception for the transport layer to act on. * - * @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled). + * @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device. + * Currently always `false` — all known BLE exceptions can resolve without user intervention (BT toggling, permission + * grants, transient GATT errors). Reserved for future use. * @property gattStatus the platform GATT status code when available (Android-specific). * @property message a human-readable description of the failure. */ @@ -50,6 +52,9 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) { is GattRequestRejectedException -> BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)") is UnmetRequirementException -> - BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable") + // Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the + // device (user re-enables BT, or grants permission). Surface as transient so the transport keeps + // retrying; UI can show a hint based on the message. + BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable") else -> null } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index a60dc85c5..022f3548d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -60,6 +60,7 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Config import org.meshtastic.proto.Telemetry import org.meshtastic.proto.ToRadio +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit @@ -211,11 +212,11 @@ class MeshConnectionManagerImpl( } } - private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) { + private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) { handshakeTimeout?.cancel() handshakeTimeout = scope.handledLaunch { - delay(HANDSHAKE_TIMEOUT) + delay(timeout) if (serviceRepository.connectionState.value is ConnectionState.Connecting) { // Attempt one retry. Note: the firmware silently drops identical consecutive // writes (per-connection dedup). If the first want_config_id was received and @@ -291,13 +292,13 @@ class MeshConnectionManagerImpl( override fun startConfigOnly() { val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) } - startHandshakeStallGuard(1, action) + startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action) action() } override fun startNodeInfoOnly() { val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) } - startHandshakeStallGuard(2, action) + startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action) action() } @@ -404,7 +405,14 @@ class MeshConnectionManagerImpl( */ private const val PRE_HANDSHAKE_SETTLE_MS = 100L - private val HANDSHAKE_TIMEOUT = 30.seconds + private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds + + /** + * Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes. + * 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+ + * nodes. + */ + private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds // Shorter window for the retry attempt: if the device genuinely didn't receive the // first want_config_id the retry completes within a few seconds. Waiting another 30s diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt index bc3558800..0f7985276 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt @@ -108,7 +108,10 @@ class SerialRadioTransport( "Uptime: ${uptime}ms, " + "Packets RX: $packetsReceived ($bytesReceived bytes)" } - onDeviceDisconnect(false) + // USB unplug / cable error is transient — the transport will reconnect when + // the device is replugged or the OS re-enumerates the port. Only an explicit + // close() (user disconnects) should signal a permanent disconnect. + onDeviceDisconnect(waitForStopped = false, isPermanent = false) } }, ) diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt index b2ccf6545..d8b14be03 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt @@ -87,6 +87,11 @@ internal class SerialConnectionImpl( port.open(usbDeviceConnection) port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) + + // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as + // present and starts its serial-side Meshtastic protocol. Empirically, omitting these + // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at + // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion. port.dtr = true port.rts = true diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt index b4773dff3..c5080ec14 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt @@ -54,9 +54,7 @@ class UsbRepository( _serialDevices .mapLatest { serialDevices -> val serialProber = usbSerialProberLazy.value - buildMap { - serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } } - } + buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } } } .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) @@ -83,6 +81,8 @@ class UsbRepository( processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() } } - private suspend fun refreshStateInternal() = - withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) } + private suspend fun refreshStateInternal() = withContext(dispatchers.default) { + val devices = usbManagerLazy.value?.deviceList ?: emptyMap() + _serialDevices.emit(devices) + } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt index 77114ff55..f2ba25804 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt @@ -133,7 +133,11 @@ class BleRadioTransport( @Volatile private var isFullyConnected = false private var connectionJob: Job? = null - private val reconnectPolicy = BleReconnectPolicy() + + // Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService) + // own the explicit-disconnect lifecycle and will close() us when the user picks a different device or + // toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s). + private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE) private val heartbeatSender = HeartbeatSender( diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt index cef746af0..e4d250796 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt @@ -26,10 +26,11 @@ import kotlin.time.Duration.Companion.seconds /** * Encapsulates the BLE reconnection policy with exponential backoff. * - * The policy tracks consecutive failures and decides whether to retry, signal a transient disconnect (DeviceSleep), or - * give up permanently. + * The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep). + * When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns; + * set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely. * - * @param maxFailures maximum consecutive failures before giving up permanently + * @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely * @param failureThreshold after this many consecutive failures, signal a transient disconnect * @param settleDelay delay before each connection attempt to let the BLE stack settle * @param minStableConnection minimum time a connection must stay up to be considered "stable" @@ -148,7 +149,18 @@ class BleReconnectPolicy( companion object { const val DEFAULT_MAX_FAILURES = 10 const val DEFAULT_FAILURE_THRESHOLD = 3 - val DEFAULT_SETTLE_DELAY = 1.seconds + + /** + * Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side + * GATT session have time to settle. + * + * Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between + * disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the + * firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose + * to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more + * mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same. + */ + val DEFAULT_SETTLE_DELAY = 3.seconds val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds internal val RECONNECT_BASE_DELAY = 5.seconds diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt index ac912346a..8c689dbcb 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt @@ -37,18 +37,20 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p override suspend fun close() { Logger.d { "Closing stream for good" } - onDeviceDisconnect(true) + onDeviceDisconnect(waitForStopped = true, isPermanent = true) } /** - * Notify the transport callback that our device has gone away, but wait for it to come back. + * Signals the transport callback that the device has disconnected and optionally waits for the transport to stop. * * @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside * transport callbacks - * @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g. - * TCP transient disconnect). Defaults to true for serial — subclasses may override with false. + * @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O + * errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS + * re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to + * signal a user-initiated terminal disconnect. */ - protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) { + protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) { callback.onDisconnect(isPermanent = isPermanent) } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt index f1049f897..840dc214a 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt @@ -22,6 +22,7 @@ import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy @@ -95,10 +96,10 @@ class BleRadioTransportTest { * [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep * timeout in [MeshConnectionManagerImpl]). * - * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses, - * connectAndAwait throws, backoff 5 s starts t = 6 000 ms — backoff ends t = 7 000 ms — iteration 2 settle delay - * elapses, connectAndAwait throws, backoff 10 s starts t = 17 000 ms — backoff ends t = 18 000 ms — iteration 3 - * settle delay elapses, connectAndAwait throws → onDisconnect called + * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1 + * settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms — + * iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24 + * 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called */ @Test fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest { @@ -119,10 +120,10 @@ class BleRadioTransportTest { ) bleTransport.start() - // Advance through exactly 3 failure iterations (≈18 001 ms virtual time). + // Advance through exactly 3 failure iterations (≈24 001 ms virtual time). // The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended // and advanceTimeBy returns cleanly. - advanceTimeBy(18_001L) + advanceTimeBy(24_001L) verify { service.onDisconnect(any(), any()) } @@ -131,16 +132,17 @@ class BleRadioTransportTest { } /** - * After [BleReconnectPolicy.DEFAULT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and - * signal a permanent disconnect. This prevents infinite battery drain when the device is genuinely offline. + * Reconnect policy must NEVER give up on its own. The transport is only ever instantiated for the user-selected + * device, and explicit-disconnect is owned by the service layer (close()). Even after a sustained failure storm — + * well beyond the legacy [BleReconnectPolicy.DEFAULT_MAX_FAILURES] — the transport must keep retrying and must + * never call `onDisconnect(isPermanent = true)` from the give-up path. * - * Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw + - * backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s - * settle + 5+10+20+40+60+60+60+60+60 = 10 + 375 = 385s ≈ 385_000ms We use a generous 400_000ms to cover any timing - * variance. + * Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw + + * backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s + * settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance. */ @Test - fun `reconnect loop stops after DEFAULT_MAX_FAILURES with permanent disconnect`() = runTest { + fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest { val device = FakeBleDevice(address = address, name = "Test Device") bluetoothRepository.bond(device) @@ -158,11 +160,13 @@ class BleRadioTransportTest { ) bleTransport.start() - // Advance enough time for all 10 failures to occur. - advanceTimeBy(400_001L) + // Run well past where the legacy policy (maxFailures = 10) would have given up. + advanceTimeBy(800_001L) - // Should have been called with isPermanent=true at least once (the final call). - verify { service.onDisconnect(isPermanent = true, errorMessage = any()) } + // Transient disconnects (isPermanent = false) are expected once the failure threshold is hit; + // the policy must NEVER signal a permanent disconnect on its own. Only explicit close() + // (verified separately by the service layer) may emit isPermanent = true. + verify(mode = VerifyMode.not) { service.onDisconnect(isPermanent = true, errorMessage = any()) } bleTransport.close() } diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt index 354c4cd30..202d8de57 100644 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt @@ -78,7 +78,11 @@ open class TcpRadioTransport( Logger.d { "[$address] Closing TCP transport" } closing = true transport.stop() - callback.onDisconnect(isPermanent = true) + // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the + // service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting + // it from close() caused a double-disconnect and prevented the auto-reconnect loop from + // owning its own lifecycle. The `closing` guard above suppresses the listener's transient + // disconnect during teardown. } override fun keepAlive() { diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt index a3f34d67e..45ba70eb7 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt @@ -129,7 +129,10 @@ private constructor( // Ignore errors during port close } if (isActive) { - onDeviceDisconnect(true) + // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as + // transient — the user did not explicitly disconnect, and the port may come + // back when the device is replugged or the OS re-enumerates it. + onDeviceDisconnect(waitForStopped = true, isPermanent = false) } } } @@ -169,8 +172,10 @@ private constructor( private const val READ_TIMEOUT_MS = 100 /** - * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent - * disconnect to the [callback] and returns the (non-connected) instance. + * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient + * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as + * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the + * user grants permission); only an explicit close should signal a permanent disconnect. */ fun open( portName: String, @@ -183,7 +188,7 @@ private constructor( if (!transport.startConnection()) { val errorMessage = diagnoseOpenFailure(portName) Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" } - callback.onDisconnect(isPermanent = true, errorMessage = errorMessage) + callback.onDisconnect(isPermanent = false, errorMessage = errorMessage) } return transport } diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt index b0a3d738c..b6999aadc 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt @@ -60,7 +60,14 @@ class AndroidGetDiscoveredDevicesUseCase( override fun invoke(showMock: Boolean): Flow { val nodeDb = nodeRepository.nodeDBbyNum - val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } } + // Filter out non-Meshtastic peripherals (headphones, cars, watches, etc.). + // BluetoothAdapter.bondedDevices returns every bonded device on the phone, so we + // must restrict the picker to entries whose advertised name matches the + // Meshtastic firmware pattern (see MeshtasticBleConstants.BLE_NAME_PATTERN). + val bondedBleFlow = + bluetoothRepository.state.map { ble -> + ble.bondedDevices.filter { it.getMeshtasticShortName() != null }.map { DeviceListEntry.Ble(it) } + } val processedTcpFlow = combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {