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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] =?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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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) { From 2b14467de3925a923313bf676cd9814307a394ce Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:02:07 -0500 Subject: [PATCH 28/65] fix(fdroid): prevent NotImplementedError crash on firmware release fetch (#5197) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/app/di/FDroidNetworkModule.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt index fba7a417f..a32016972 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt @@ -25,12 +25,18 @@ import org.meshtastic.core.network.service.ApiService @Module class FDroidNetworkModule { + /** + * F-Droid builds intentionally avoid network calls to the Meshtastic API. + * + * We throw [UnsupportedOperationException] (an [Exception], not an [Error]) so that `safeCatching {}` in the + * repositories captures the failure and falls back to the bundled JSON assets instead of crashing the app. + */ @Single fun provideApiService(): ApiService = object : ApiService { override suspend fun getDeviceHardware(): List = - throw NotImplementedError("API calls to getDeviceHardware are not supported on Fdroid builds.") + throw UnsupportedOperationException("getDeviceHardware is not supported on F-Droid builds.") override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = - throw NotImplementedError("API calls to getFirmwareReleases are not supported on Fdroid builds.") + throw UnsupportedOperationException("getFirmwareReleases is not supported on F-Droid builds.") } } From 02cb12cac44f4ca6ec211a5222b1b24f561783b3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:01:30 -0500 Subject: [PATCH 29/65] chore(deps): update org.jetbrains.androidx.navigation3:navigation3-ui to v1.1.0 (#5199) 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 baf89fb1d..e4dccddf5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ datastore = "1.2.1" glance = "1.2.0-rc01" lifecycle = "2.10.0" jetbrains-lifecycle = "2.11.0-alpha03" -navigation3 = "1.1.0-rc01" +navigation3 = "1.1.0" paging = "3.4.2" room = "3.0.0-alpha03" koin = "4.2.1" From 38c2e9fb335fcb577568466a4923d1cd9f077575 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:06:19 -0500 Subject: [PATCH 30/65] fix(compass): stop coarse network fixes from clobbering GPS fixes (#5200) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../compass/AndroidPhoneLocationProvider.kt | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt index 1e3d763be..39f6d03d0 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt @@ -67,8 +67,16 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis val listener = object : LocationListenerCompat { override fun onLocationChanged(location: Location) { - lastLocation = location - sendUpdate() + // Subscribing to both GPS and NETWORK providers means coarse Wi-Fi/cell fixes + // would otherwise overwrite a fresh, accurate GPS fix on every callback, + // making the compass distance/bearing jitter between two positions + // (see issue #4864). Apply the canonical isBetterLocation filter so we + // prefer the most accurate, recent fix and fall back to network only + // when no usable GPS fix is available. + if (isBetterLocation(location, lastLocation)) { + lastLocation = location + sendUpdate() + } } override fun onProviderEnabled(provider: String) = sendUpdate() @@ -88,7 +96,9 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis lastLocation = providers .mapNotNull { provider -> locationManager.getLastKnownLocation(provider) } - .maxByOrNull { it.time } + .reduceOrNull { best, candidate -> + if (isBetterLocation(candidate, best)) candidate else best + } sendUpdate() @@ -124,5 +134,41 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis companion object { private const val MIN_UPDATE_INTERVAL_MS = 1_000L + private const val SIGNIFICANTLY_NEWER_MS = 2 * 60 * 1000L + private const val SIGNIFICANTLY_LESS_ACCURATE_M = 200f + + /** + * Canonical Android "is this fix better than the last one?" comparison (adapted from the framework's + * LocationListener guide). Without this, subscribing to GPS_PROVIDER and NETWORK_PROVIDER simultaneously causes + * coarse Wi-Fi/cell fixes to overwrite recent fine GPS fixes, making the compass distance and bearing jump + * between two positions (issue #4864). + */ + @Suppress("ReturnCount") + internal fun isBetterLocation(candidate: Location, current: Location?): Boolean { + if (current == null) return true + + val timeDelta = candidate.time - current.time + val isSignificantlyNewer = timeDelta > SIGNIFICANTLY_NEWER_MS + val isSignificantlyOlder = timeDelta < -SIGNIFICANTLY_NEWER_MS + val isNewer = timeDelta > 0 + + // A much newer fix is always preferred even if accuracy is worse — the device + // has likely moved, so a stale "accurate" fix is worse than a fresh coarse one. + if (isSignificantlyNewer) return true + if (isSignificantlyOlder) return false + + val accuracyDelta = candidate.accuracy - current.accuracy + val isMoreAccurate = accuracyDelta < 0f + val isLessAccurate = accuracyDelta > 0f + val isSignificantlyLessAccurate = accuracyDelta > SIGNIFICANTLY_LESS_ACCURATE_M + val isFromSameProvider = candidate.provider == current.provider + + return when { + isMoreAccurate -> true + isNewer && !isLessAccurate -> true + isNewer && !isSignificantlyLessAccurate && isFromSameProvider -> true + else -> false + } + } } } From dfcfac18b1404c33fdd4604ae8f31680a9fa550f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:38:37 -0500 Subject: [PATCH 31/65] chore(deps): update com.android.tools:common to v32.2.0 (#5202) 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 e4dccddf5..aa7ce0faa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -243,7 +243,7 @@ vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ve # Build Logic android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" } -android-tools-common = { module = "com.android.tools:common", version = "32.1.1" } +android-tools-common = { module = "com.android.tools:common", version = "32.2.0" } androidx-room-gradlePlugin = { module = "androidx.room3:room3-gradle-plugin", version.ref = "room" } compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } compose-multiplatform-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" } From e8db1495dcb80a7142150aed22f2663da19a6e8c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:44:16 +0000 Subject: [PATCH 32/65] chore(deps): update agp to v9.2.0 (#5201) 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 aa7ce0faa..301531f82 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ xmlutil = "0.91.3" # Android -agp = "9.2.0-rc01" +agp = "9.2.0" appcompat = "1.7.1" accompanist = "0.37.3" From 4fd52ffb46135a4f3d68d75c3f71fd90d7a92d94 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:44:44 -0500 Subject: [PATCH 33/65] fix(canned-messages): enable multiline text editing for long message lists (#5203) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/core/ui/component/EditTextPreference.kt | 4 +++- .../settings/radio/component/CannedMessageConfigItemList.kt | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt index 43a19ef1b..0e4699a28 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt @@ -205,6 +205,7 @@ fun EditTextPreference( onFocusChanged: (FocusState) -> Unit = {}, trailingIcon: (@Composable () -> Unit)? = null, visualTransformation: VisualTransformation = VisualTransformation.None, + multiline: Boolean = false, ) { var isFocused by remember { mutableStateOf(false) } @@ -212,7 +213,8 @@ fun EditTextPreference( OutlinedTextField( modifier = Modifier.fillMaxWidth().onFocusEvent { onFocusChanged(it) }, value = value, - singleLine = true, + singleLine = !multiline, + maxLines = if (multiline) 5 else 1, enabled = enabled, isError = isError, onValueChange = { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt index 4c6cdc9f5..89df9d4be 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/CannedMessageConfigItemList.kt @@ -181,6 +181,7 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Uni KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { messagesInput = it }, + multiline = true, ) } } From 6e1a500ca70a216fa63ed35ad6c7ab94e09104f3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:48:39 -0500 Subject: [PATCH 34/65] fix(settings): restore Import/Export button functionality in #4913 (#5204) --- .../kotlin/org/meshtastic/feature/settings/SettingsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 82cd4b7be..f30a12d52 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -90,7 +90,7 @@ fun SettingsScreen( val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var deviceProfile by remember { mutableStateOf(null) } - var showEditDeviceProfileDialog by rememberSaveable { mutableStateOf(false) } + var showEditDeviceProfileDialog by remember { mutableStateOf(false) } val importConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { From 6b0fcc771c192c4619d473c584ec44ec1b18cdc8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:54:02 -0500 Subject: [PATCH 35/65] chore(deps): update core/proto/src/main/proto digest to d004f50 (#5205) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- core/proto/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index 4d5b500df..d004f503b 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c +Subproject commit d004f503bbf3498fd689013a794e2a0e384b3f19 From f22e5a70d95acd7198b608dbb9faaf7f63775be5 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:12:15 -0500 Subject: [PATCH 36/65] feat(firmware): nRF52 BLE Legacy DFU support (#5209) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/core/ble/KablePlatformSetup.kt | 7 + .../org/meshtastic/core/ble/BleConnection.kt | 7 + .../meshtastic/core/ble/KableBleConnection.kt | 2 + .../meshtastic/core/ble/KablePlatformSetup.kt | 8 + .../org/meshtastic/core/ble/NoopStubs.kt | 2 + .../meshtastic/core/ble/KablePlatformSetup.kt | 2 + .../org/meshtastic/core/testing/FakeBle.kt | 10 +- .../firmware/FirmwareUpdateViewModel.kt | 2 +- .../firmware/ota/dfu/DfuUploadTransport.kt | 44 ++ .../firmware/ota/dfu/LegacyDfuProtocol.kt | 199 +++++++ .../firmware/ota/dfu/LegacyDfuTransport.kt | 546 ++++++++++++++++++ .../firmware/ota/dfu/SecureDfuHandler.kt | 92 ++- .../firmware/ota/dfu/SecureDfuProtocol.kt | 29 + .../firmware/ota/dfu/SecureDfuTransport.kt | 241 +++++--- .../firmware/ota/dfu/LegacyDfuProtocolTest.kt | 124 ++++ .../ota/dfu/LegacyDfuTransportTest.kt | 535 +++++++++++++++++ .../ota/dfu/SecureDfuTransportTest.kt | 32 +- 17 files changed, 1795 insertions(+), 87 deletions(-) create mode 100644 feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuUploadTransport.kt create mode 100644 feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocol.kt create mode 100644 feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt create mode 100644 feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocolTest.kt create mode 100644 feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index b0617635a..018d8f9fc 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -66,3 +66,10 @@ internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? { val mtu = (this as? AndroidPeripheral)?.mtu?.value ?: return null return (mtu - ATT_HEADER_SIZE).takeIf { it > 0 } } + +internal actual fun Peripheral.requestHighConnectionPriority(): Boolean { + val androidPeripheral = this as? AndroidPeripheral ?: return false + return runCatching { androidPeripheral.requestConnectionPriority(AndroidPeripheral.Priority.High) } + .onFailure { Logger.w(it) { "requestConnectionPriority(High) threw" } } + .getOrDefault(false) +} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt index 59cf134de..5a8b67ce1 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -65,6 +65,13 @@ interface BleConnection { /** Returns the maximum write value length for the given write type, or `null` if unknown. */ fun maximumWriteValueLength(writeType: BleWriteType): Int? + + /** + * Asks the platform to switch to a high-throughput / low-latency BLE connection priority for the duration of the + * connection. Used by latency-sensitive flows like firmware updates. Returns `true` if the request was issued. + * Default implementation returns `false` for platforms that don't support it. + */ + fun requestHighConnectionPriority(): Boolean = false } /** Represents a BLE service for commonMain. */ diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index f658d234c..f3b6d9383 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -231,6 +231,8 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength() + override fun requestHighConnectionPriority(): Boolean = peripheral?.requestHighConnectionPriority() == true + /** Ensures the previous peripheral's GATT resources are fully released. */ private suspend fun cleanUpPeripheral(tag: String) { withContext(NonCancellable) { safeClosePeripheral(tag) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index d27ba2225..a1f0baaef 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -30,3 +30,11 @@ internal expect fun createPeripheral(address: String, builderAction: PeripheralB * MTU has not yet been negotiated on this platform. */ internal expect fun Peripheral.negotiatedMaxWriteLength(): Int? + +/** + * Requests the highest-throughput BLE connection priority (smallest connection interval) supported by the platform. + * + * Returns `true` if the request was issued successfully. On platforms without an equivalent API (JVM/iOS) this is a + * no-op returning `false`. Used by latency-sensitive flows such as DFU firmware streaming. + */ +internal expect fun Peripheral.requestHighConnectionPriority(): Boolean diff --git a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt index 3ad0b6c4d..85eb4fe7f 100644 --- a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt +++ b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt @@ -28,3 +28,5 @@ internal actual fun createPeripheral(address: String, builderAction: PeripheralB throw UnsupportedOperationException("iOS Peripheral not yet implemented") internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null + +internal actual fun Peripheral.requestHighConnectionPriority(): Boolean = false diff --git a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index 99ff6885c..462d7345d 100644 --- a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -31,4 +31,6 @@ internal actual fun createPeripheral(address: String, builderAction: PeripheralB // so callers can size their writes without falling back to an overly conservative minimum. internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = DEFAULT_JVM_MTU +internal actual fun Peripheral.requestHighConnectionPriority(): Boolean = false + private const val DEFAULT_JVM_MTU = 512 diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt index e5280ec45..f2001da86 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt @@ -109,6 +109,9 @@ class FakeBleConnection : /** Number of times [disconnect] has been invoked. */ var disconnectCalls: Int = 0 + /** Service UUIDs that should appear missing — `profile()` throws `NoSuchElementException` for these. */ + val missingServices: MutableSet = mutableSetOf() + val service = FakeBleService() override suspend fun connect(device: BleDevice) { @@ -149,7 +152,12 @@ class FakeBleConnection : serviceUuid: Uuid, timeout: Duration, setup: suspend CoroutineScope.(BleService) -> T, - ): T = CoroutineScope(Dispatchers.Unconfined).setup(service) + ): T { + if (serviceUuid in missingServices) { + throw NoSuchElementException("Service $serviceUuid not found") + } + return CoroutineScope(Dispatchers.Unconfined).setup(service) + } override fun maximumWriteValueLength(writeType: BleWriteType): Int? = maxWriteValueLength } 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 f8ff9fcac..447944772 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 @@ -239,7 +239,7 @@ class FirmwareUpdateViewModel( tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } } catch (e: CancellationException) { - Logger.i { "Firmware update cancelled" } + Logger.w(e) { "Firmware update cancelled — cause: ${e.cause} message: ${e.message}" } _state.value = FirmwareUpdateState.Idle checkForUpdates() throw e diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuUploadTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuUploadTransport.kt new file mode 100644 index 000000000..10ae352dc --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuUploadTransport.kt @@ -0,0 +1,44 @@ +/* + * 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.feature.firmware.ota.dfu + +/** + * Common upload-time surface implemented by both [SecureDfuTransport] (Nordic Secure DFU, service `FE59`) and + * [LegacyDfuTransport] (Nordic Legacy DFU / Adafruit BLEDfu, service `1530`). + * + * The choice of which implementation to use is made by the handler after the device reboots into bootloader mode, based + * on which DFU service is exposed. + */ +interface DfuUploadTransport { + /** Establish the GATT session with the device in DFU mode. */ + suspend fun connectToDfuMode(): Result + + /** Upload the init packet (`.dat`) and have the device validate it. */ + suspend fun transferInitPacket(initPacket: ByteArray): Result + + /** + * Upload the firmware binary (`.bin`). [onProgress] is invoked with a value in `[0.0, 1.0]` after each protocol + * checkpoint (PRN window or end-of-image). + */ + suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result + + /** Best-effort abort of any in-flight transfer (for cancellation / error recovery). Never throws. */ + suspend fun abort() + + /** Disconnect and release resources. */ + suspend fun close() +} diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocol.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocol.kt new file mode 100644 index 000000000..8dd330f99 --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocol.kt @@ -0,0 +1,199 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.firmware.ota.dfu + +import kotlin.uuid.Uuid + +// --------------------------------------------------------------------------- +// Nordic Legacy DFU – additional service characteristic UUIDs +// (Service + Control Point are declared in `LegacyDfuUuids` in +// SecureDfuProtocol.kt — they're shared with the Phase-1 buttonless trigger.) +// --------------------------------------------------------------------------- + +/** Packet characteristic — accepts WRITE_NO_RESPONSE for image sizes, init data, and firmware bytes. */ +internal val LEGACY_DFU_PACKET_UUID: Uuid = Uuid.parse("00001532-1212-EFDE-1523-785FEABCD123") + +/** + * DFU Version characteristic — optional; uint16 LE giving bootloader DFU version. Used to gate the extended init-packet + * flow (≥ 5 ⇒ START/COMPLETE bracket; ≤ 4 ⇒ unsupported old SDK). + */ +internal val LEGACY_DFU_VERSION_UUID: Uuid = Uuid.parse("00001534-1212-EFDE-1523-785FEABCD123") + +// --------------------------------------------------------------------------- +// Protocol opcodes (Nordic SDK 11/12 / Adafruit BLEDfu) +// --------------------------------------------------------------------------- + +internal object LegacyDfuOpcode { + const val START_DFU: Byte = 0x01 + const val INIT_DFU_PARAMS: Byte = 0x02 + const val RECEIVE_FIRMWARE_IMAGE: Byte = 0x03 + const val VALIDATE: Byte = 0x04 + const val ACTIVATE_AND_RESET: Byte = 0x05 + const val RESET: Byte = 0x06 + const val PACKET_RECEIPT_NOTIF_REQ: Byte = 0x08 + + /** Prefix on every Control-Point response notification. */ + const val RESPONSE_CODE: Byte = 0x10 + + /** Prefix on every Packet-Receipt notification (carries `[bytes_received_u32_le]`). */ + const val PACKET_RECEIPT: Byte = 0x11 + + /** Sub-opcode of `INIT_DFU_PARAMS`: marks beginning of init-packet stream. */ + const val INIT_PARAMS_START: Byte = 0x00 + + /** Sub-opcode of `INIT_DFU_PARAMS`: marks end of init-packet stream. */ + const val INIT_PARAMS_COMPLETE: Byte = 0x01 +} + +/** + * `START_DFU` image-type bitmask. Meshtastic only ever ships application updates over OTA, so the transport hard-codes + * [APPLICATION]. + */ +internal object LegacyDfuImageType { + const val SOFT_DEVICE: Byte = 0x01 + const val BOOTLOADER: Byte = 0x02 + const val APPLICATION: Byte = 0x04 +} + +/** Result codes returned in the third byte of a response notification. */ +internal object LegacyDfuStatus { + const val SUCCESS: Byte = 0x01 + const val INVALID_STATE: Byte = 0x02 + const val NOT_SUPPORTED: Byte = 0x03 + const val DATA_SIZE_EXCEEDS_LIMIT: Byte = 0x04 + const val CRC_ERROR: Byte = 0x05 + const val OPERATION_FAILED: Byte = 0x06 + + fun describe(status: Byte): String = when (status) { + SUCCESS -> "SUCCESS" + INVALID_STATE -> "INVALID_STATE" + NOT_SUPPORTED -> "NOT_SUPPORTED" + DATA_SIZE_EXCEEDS_LIMIT -> "DATA_SIZE_EXCEEDS_LIMIT" + CRC_ERROR -> "CRC_ERROR" + OPERATION_FAILED -> "OPERATION_FAILED" + else -> "UNKNOWN(0x${status.toUByte().toString(16).padStart(2, '0')})" + } +} + +// --------------------------------------------------------------------------- +// Response parsing +// --------------------------------------------------------------------------- + +/** Parsed notification from the Legacy DFU Control Point characteristic. */ +internal sealed class LegacyDfuResponse { + + /** `[0x10, requestOpcode, 0x01]` — request succeeded. */ + data class Success(val requestOpcode: Byte) : LegacyDfuResponse() + + /** `[0x10, requestOpcode, status]` where `status != 0x01` — device rejected the request. */ + data class Failure(val requestOpcode: Byte, val status: Byte) : LegacyDfuResponse() + + /** `[0x11, bytes_received_u32_le]` — periodic packet-receipt notification. */ + data class PacketReceipt(val bytesReceived: Long) : LegacyDfuResponse() + + /** Unrecognised bytes — logged, surfaced as a protocol error. */ + data class Unknown(val raw: ByteArray) : LegacyDfuResponse() { + override fun equals(other: Any?) = other is Unknown && raw.contentEquals(other.raw) + + override fun hashCode() = raw.contentHashCode() + } + + companion object { + @Suppress("ReturnCount") + fun parse(data: ByteArray): LegacyDfuResponse { + if (data.isEmpty()) return Unknown(data) + return when (data[0]) { + LegacyDfuOpcode.RESPONSE_CODE -> { + if (data.size < 3) return Unknown(data) + val requestOpcode = data[1] + val status = data[2] + if (status == LegacyDfuStatus.SUCCESS) { + Success(requestOpcode) + } else { + Failure(requestOpcode, status) + } + } + LegacyDfuOpcode.PACKET_RECEIPT -> { + if (data.size < 5) return Unknown(data) + val bytes = + (data[1].toLong() and 0xFF) or + ((data[2].toLong() and 0xFF) shl 8) or + ((data[3].toLong() and 0xFF) shl 16) or + ((data[4].toLong() and 0xFF) shl 24) + PacketReceipt(bytes) + } + else -> Unknown(data) + } + } + } +} + +// --------------------------------------------------------------------------- +// Payload builders +// --------------------------------------------------------------------------- + +/** + * Build the 12-byte image-sizes payload written to the Packet characteristic immediately after `START_DFU`. + * + * Layout: `[soft_device_size_u32_le, bootloader_size_u32_le, app_size_u32_le]`. Meshtastic only updates the + * application, so [softDeviceSize] and [bootloaderSize] default to 0. + */ +internal fun legacyImageSizesPayload(appSize: Int, softDeviceSize: Int = 0, bootloaderSize: Int = 0): ByteArray = + intToLeBytes(softDeviceSize) + intToLeBytes(bootloaderSize) + intToLeBytes(appSize) + +/** Build the 3-byte `PACKET_RECEIPT_NOTIF_REQ` payload: opcode + uint16-LE PRN value. */ +internal fun legacyPrnRequestPayload(packets: Int): ByteArray = byteArrayOf( + LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ, + (packets and 0xFF).toByte(), + ((packets ushr 8) and 0xFF).toByte(), +) + +// --------------------------------------------------------------------------- +// Exceptions +// --------------------------------------------------------------------------- + +/** + * Errors specific to the Nordic Legacy DFU protocol. These are a subtype of [DfuException] so the existing handler + * error-path code (which catches `DfuException`) covers both protocols. + */ +sealed class LegacyDfuException(message: String, cause: Throwable? = null) : DfuException(message, cause) { + /** Device returned a non-success status for a given opcode. */ + class ProtocolError(val requestOpcode: Byte, val status: Byte) : + LegacyDfuException( + "Legacy DFU protocol error: opcode=0x${requestOpcode.toUByte().toString(16).padStart(2, '0')} " + + "status=${LegacyDfuStatus.describe(status)}", + ) + + /** Bootloader exposes DFU Version characteristic with a value below 5 (Nordic SDK ≤ 6). Unsupported. */ + class UnsupportedBootloader(version: Int) : + LegacyDfuException( + "Legacy DFU bootloader version $version is too old (need ≥ 5). Please update the bootloader.", + ) + + /** Init packet ([dat]) appears to be Secure-DFU shaped (signed/CBOR), not the small legacy 14-32 B form. */ + class InitPacketNotLegacy(size: Int) : + LegacyDfuException( + "Init packet is $size bytes — too large for Legacy DFU. " + + "This .dat looks like a Secure DFU init packet; the bootloader will reject it.", + ) + + /** Bytes received reported by device differs from bytes sent past last PRN window. */ + class PacketReceiptMismatch(expected: Long, actual: Long) : + LegacyDfuException("Packet receipt mismatch: expected $expected bytes received, device reports $actual") +} diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt new file mode 100644 index 000000000..f17982c88 --- /dev/null +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt @@ -0,0 +1,546 @@ +/* + * 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 . + */ +@file:Suppress( + "MagicNumber", + "TooManyFunctions", + "ThrowsCount", + "ReturnCount", + "SwallowedException", + "TooGenericExceptionCaught", +) + +package org.meshtastic.feature.firmware.ota.dfu + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import org.meshtastic.core.ble.BleConnectionFactory +import org.meshtastic.core.ble.BleConnectionState +import org.meshtastic.core.ble.BleDevice +import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.ble.BleWriteType +import org.meshtastic.core.common.util.safeCatching +import org.meshtastic.feature.firmware.ota.calculateMacPlusOne +import org.meshtastic.feature.firmware.ota.scanForBleDevice +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Kable-based transport for the Nordic **Legacy DFU** protocol (Nordic SDK 11/12 / Adafruit `BLEDfu`). + * + * Most nRF52 boards in the field — including the RAK4631 with the recommended Adafruit/oltaco "OTAFIX" bootloader — + * speak Legacy DFU rather than Secure DFU. The two protocols share nothing at the upload layer: + * - Different service & characteristic UUIDs (`1530`/`1531`/`1532` vs `FE59`/`8EC9…`). + * - Different opcodes; init packet is sent on the Packet char between two control-point writes (vs Secure's + * CREATE/PACKET/EXECUTE object flow). + * - PRN payload is bytes-received uint32 (vs Secure's offset+CRC32). + * - No CRC32 in the protocol — image integrity relies on the device's CRC16 in the init packet. + * + * Phase-1 buttonless trigger is shared with [SecureDfuTransport] (see `triggerButtonlessDfu` there). + */ +class LegacyDfuTransport( + private val scanner: BleScanner, + connectionFactory: BleConnectionFactory, + private val address: String, + dispatcher: CoroutineDispatcher, +) : DfuUploadTransport { + private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) + private val bleConnection = connectionFactory.create(transportScope, "Legacy DFU") + + /** Receives parsed responses from the Control Point characteristic. */ + private val notificationChannel = Channel(Channel.UNLIMITED) + + /** Name advertised by the device in DFU mode (e.g. `4631_DFU`). Captured in [connectToDfuMode]. */ + private var dfuAdvertisedName: String? = null + + // --------------------------------------------------------------------------- + // Phase 2: Connect to device in DFU mode + // --------------------------------------------------------------------------- + + /** + * Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling + * notifications on the Control Point. + * + * Best-effort reads the optional DFU Version characteristic to gate against unsupported old (SDK ≤ 6) bootloaders. + */ + override suspend fun connectToDfuMode(): Result = safeCatching { + val dfuAddress = calculateMacPlusOne(address) + val targetAddresses = setOf(address, dfuAddress) + Logger.i { "Legacy DFU: Scanning for DFU mode device at $targetAddresses..." } + + val device = + scanForDevice { d -> d.address in targetAddresses } + ?: throw DfuException.ConnectionFailed("DFU mode device not found. Tried: $targetAddresses") + + Logger.i { "Legacy DFU: Found DFU mode device at ${device.address} (name=${device.name}), connecting..." } + dfuAdvertisedName = device.name + + bleConnection.connectionState + .onEach { Logger.d { "Legacy DFU: Connection state → $it" } } + .launchIn(transportScope) + + val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT) + if (connected is BleConnectionState.Disconnected) { + throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address}") + } + + bleConnection.profile(LegacyDfuUuids.SERVICE) { service -> + val controlChar = service.characteristic(LegacyDfuUuids.CONTROL_POINT) + + val subscribed = CompletableDeferred() + service + .observe(controlChar) + .onEach { bytes -> + if (!subscribed.isCompleted) { + Logger.d { "Legacy DFU: Control Point subscribed" } + subscribed.complete(Unit) + } + val parsed = LegacyDfuResponse.parse(bytes) + Logger.d { "Legacy DFU: Notification → $parsed" } + notificationChannel.trySend(parsed) + } + .catch { e -> + if (!subscribed.isCompleted) subscribed.completeExceptionally(e) + Logger.e(e) { "Legacy DFU: Control Point notification error" } + } + .launchIn(this) + + delay(SUBSCRIPTION_SETTLE) + if (!subscribed.isCompleted) subscribed.complete(Unit) + subscribed.await() + + // Best-effort DFU Version read — gate out unsupported old bootloaders (SDK ≤ 6). + val versionChar = service.characteristic(LEGACY_DFU_VERSION_UUID) + val version = + runCatching { service.read(versionChar) } + .map { bytes -> + if (bytes.size >= 2) (bytes[0].toInt() and 0xFF) or ((bytes[1].toInt() and 0xFF) shl 8) else -1 + } + .getOrElse { -1 } + Logger.i { "Legacy DFU: DFU Version characteristic = $version (-1 ⇒ absent / unreadable)" } + if (version in 1..MIN_SUPPORTED_DFU_VERSION - 1) { + throw LegacyDfuException.UnsupportedBootloader(version) + } + + Logger.i { "Legacy DFU: Connected and ready (${device.address})" } + } + } + + // --------------------------------------------------------------------------- + // Phase 3: Init packet transfer (.dat) + // --------------------------------------------------------------------------- + + /** + * Sends the legacy DFU init packet using the SDK 7+ extended-init flow: + * 1. `START_DFU [APP]` → device prepares. + * 2. Image sizes `[0u32, 0u32, appSize_u32]` on the Packet characteristic. + * 3. `INIT_PARAMS_START` → init bytes on Packet → `INIT_PARAMS_COMPLETE`. + * + * The legacy init packet for an APP image is typically 14 bytes (SDK 7) or 32 bytes (SDK 11 with signature). Any + * `.dat` significantly larger than that almost certainly belongs to a Secure DFU build that has been mis-packaged + * for a legacy bootloader — we surface that with a helpful error rather than letting the device reject it. + * + * The init packet is bracketed by START/COMPLETE, but the upload itself is intermixed with image sizes. To match + * Nordic's library, the [initPacket] argument here is the legacy init bytes; the firmware [transferFirmware] method + * needs to be called next to provide the actual image (and the size we include here must match its length). + * + * Because `transferInitPacket` is called before [transferFirmware], we don't yet know the firmware size when + * sending image sizes. To keep the [DfuUploadTransport] contract clean we instead send image sizes lazily inside + * [transferFirmware]'s START phase. **This method only writes START_DFU + brackets the init packet.** Any + * outstanding image-size write happens at the start of [transferFirmware]. + */ + override suspend fun transferInitPacket(initPacket: ByteArray): Result = safeCatching { + if (initPacket.size > MAX_REASONABLE_LEGACY_INIT_SIZE) { + throw LegacyDfuException.InitPacketNotLegacy(initPacket.size) + } + Logger.i { "Legacy DFU: Stashing init packet (${initPacket.size} bytes) for transfer in Phase 4." } + pendingInitPacket = initPacket + } + + /** Init packet stashed by [transferInitPacket]; flushed at the start of [transferFirmware]. */ + private var pendingInitPacket: ByteArray? = null + + // --------------------------------------------------------------------------- + // Phase 4: Firmware transfer (.bin) + // --------------------------------------------------------------------------- + + /** + * Drives the full upload sequence (START, init-packet brackets, PRN setup, firmware stream, validate, activate). + * + * Sequence details: + * 1. `START_DFU [0x04]` (APP image only). + * 2. Image sizes payload on Packet char: `[0u32, 0u32, firmware.size_u32]`. + * 3. Await START response. + * 4. `INIT_PARAMS_START`, init bytes on Packet, `INIT_PARAMS_COMPLETE`. Await init response. + * 5. `PRN_REQ [PRN_LE16]`. (No response.) + * 6. `RECEIVE_FIRMWARE_IMAGE`. (No response.) + * 7. Stream firmware in MTU-sized chunks. Every PRN packets, await `PacketReceipt(bytesReceived)` and verify count. + * 8. After last byte, await final response for `RECEIVE_FIRMWARE_IMAGE`. + * 9. `VALIDATE`, await response. + * 10. `ACTIVATE_AND_RESET` — device reboots; write may fail with disconnect, treat as success. + */ + @Suppress("LongMethod") + override suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = + safeCatching { + val initPacket = + pendingInitPacket + ?: throw DfuException.TransferFailed("transferInitPacket must be called before transferFirmware") + Logger.i { "Legacy DFU: Starting upload (init=${initPacket.size}B, firmware=${firmware.size}B)..." } + + // ── 1. START_DFU + image sizes on Packet, then response ───────────── + writeControlPoint(byteArrayOf(LegacyDfuOpcode.START_DFU, LegacyDfuImageType.APPLICATION)) + writePacket(legacyImageSizesPayload(appSize = firmware.size)) + requireSuccess(LegacyDfuOpcode.START_DFU, awaitResponse(START_RESPONSE_TIMEOUT)) + + // ── 2. INIT_PARAMS_START → init bytes on Packet → INIT_PARAMS_COMPLETE → response ── + writeControlPoint(byteArrayOf(LegacyDfuOpcode.INIT_DFU_PARAMS, LegacyDfuOpcode.INIT_PARAMS_START)) + writePacketChunked(initPacket) + writeControlPoint(byteArrayOf(LegacyDfuOpcode.INIT_DFU_PARAMS, LegacyDfuOpcode.INIT_PARAMS_COMPLETE)) + requireSuccess(LegacyDfuOpcode.INIT_DFU_PARAMS, awaitResponse(COMMAND_TIMEOUT)) + + // Bump the BLE link to high-throughput mode (~7.5 ms interval) before streaming. + // Default Android intervals (~30-50 ms) starve the link during sustained DFU and trigger LSTO. Mirrors + // Nordic LegacyDfuImpl.java requestConnectionPriority(CONNECTION_PRIORITY_HIGH). + val highPriorityRequested = bleConnection.requestHighConnectionPriority() + Logger.i { "Legacy DFU: requestHighConnectionPriority -> $highPriorityRequested" } + + // ── 3. PRN setup ──────────────────────────────────────────────────── + writeControlPoint(legacyPrnRequestPayload(PRN_INTERVAL_PACKETS)) + + // ── 4. RECEIVE_FIRMWARE_IMAGE ────────────────────────────────────── + writeControlPoint(byteArrayOf(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE)) + + // ── 5. Stream firmware ───────────────────────────────────────────── + streamFirmware(firmware, onProgress) + + // ── 6. Final RECEIVE_FIRMWARE_IMAGE response ──────────────────────── + requireSuccess(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE, awaitResponse(VALIDATE_TIMEOUT)) + + // ── 7. VALIDATE ──────────────────────────────────────────────────── + writeControlPoint(byteArrayOf(LegacyDfuOpcode.VALIDATE)) + requireSuccess(LegacyDfuOpcode.VALIDATE, awaitResponse(VALIDATE_TIMEOUT)) + + // ── 8. ACTIVATE_AND_RESET ────────────────────────────────────────── + // Device may reset before the GATT write ACK lands — treat any write failure / disconnect as success. + Logger.i { "Legacy DFU: Sending ACTIVATE_AND_RESET (disconnect during write is expected)" } + runCatching { writeControlPoint(byteArrayOf(LegacyDfuOpcode.ACTIVATE_AND_RESET)) } + .onFailure { Logger.i(it) { "Legacy DFU: ACTIVATE write reported failure (expected on reset)" } } + + onProgress(1f) + Logger.i { "Legacy DFU: Upload complete, device rebooting into new firmware." } + } + + /** + * Stream [firmware] to the Packet characteristic, awaiting a [LegacyDfuResponse.PacketReceipt] every + * [PRN_INTERVAL_PACKETS] packets and verifying the bytes-received count. + * + * Watches the connection state in parallel with the write loop; if the link drops mid-stream we cancel the write + * coroutine and surface a [DfuException.ConnectionFailed] immediately rather than waiting indefinitely for a write + * that will never complete. + */ + + /** + * Determine the per-packet size for firmware streaming. + * + * Returns [LEGACY_DFU_PACKET_SIZE] (20) by default for safety against stock Nordic / pre-2.1 OTAFIX bootloaders + * that overrun their flash buffer on larger writes. When the device advertises an OTAFIX-2.1+ name (`_DFU` + * suffix per https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX#changes-in-otafix-21) and the connection + * has negotiated a larger ATT MTU, returns that MTU − 3 (ATT header overhead) capped at 244 bytes. + */ + private fun computeStreamPacketSize(): Int { + val name = dfuAdvertisedName.orEmpty() + val isOtafix21 = name.endsWith(OTAFIX_NAME_SUFFIX, ignoreCase = true) + if (!isOtafix21) return LEGACY_DFU_PACKET_SIZE + val negotiated = + bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) ?: return LEGACY_DFU_PACKET_SIZE + return negotiated.coerceIn(LEGACY_DFU_PACKET_SIZE, MAX_HIGH_MTU_PACKET_SIZE) + } + + @Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "LongMethod") + private suspend fun streamFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit) { + // Default: 20-byte ATT packets (the only size accepted by stock Nordic / Adafruit pre-OTAFIX-2.1 + // bootloaders). Sending larger packets to those bootloaders overruns the flash-write buffer and + // silently bricks the device. See .agent_refs/Android-DFU-Library/.../BaseCustomDfuImpl.java:417. + // + // OTAFIX 2.1+ explicitly supports high-MTU packets ("Enables larger DFU packets for improved + // throughput when supported by the client" per the OTAFIX README). It re-uses the standard + // `_DFU` advertising-name suffix as a marker for the 2.1 feature set, so we opportunistically + // bump the packet size to the negotiated ATT MTU (minus 3 for the ATT header) when we see that name. + val mtu = computeStreamPacketSize() + var offset = 0 + Logger.i { + "Legacy DFU: Streaming ${firmware.size} bytes with packet size $mtu " + + "(advertised='${dfuAdvertisedName ?: "?"}')" + } + + coroutineScope { + // Trip-wire: cancels the streaming coroutine the moment Kable observes a disconnect. + val watcher = launch { + val state = bleConnection.connectionState.first { it is BleConnectionState.Disconnected } + Logger.w { "Legacy DFU: Link dropped mid-stream at offset $offset/${firmware.size} (state=$state)" } + throw DfuException.ConnectionFailed("BLE link dropped mid-upload at byte $offset/${firmware.size}") + } + + try { + var packetsSincePrn = 0 + var bytesAtLastPrn = 0L + bleConnection.profile(LegacyDfuUuids.SERVICE, timeout = STREAM_TIMEOUT) { service -> + val packetChar = service.characteristic(LEGACY_DFU_PACKET_UUID) + while (offset < firmware.size) { + val end = minOf(offset + mtu, firmware.size) + try { + service.write(packetChar, firmware.copyOfRange(offset, end), BleWriteType.WITHOUT_RESPONSE) + } catch (e: CancellationException) { + Logger.w(e) { + "Legacy DFU: Write CANCELLED at offset $offset/${firmware.size} cause=${e.cause}" + } + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + Logger.w(e) { "Legacy DFU: Write FAILED at offset $offset/${firmware.size}: ${e.message}" } + throw e + } + offset = end + packetsSincePrn++ + + if (packetsSincePrn >= PRN_INTERVAL_PACKETS && offset < firmware.size) { + Logger.d { "Legacy DFU: Awaiting PRN at offset $offset" } + val receipt = + try { + awaitPacketReceipt() + } catch (e: CancellationException) { + Logger.w(e) { + "Legacy DFU: awaitPacketReceipt CANCELLED at offset $offset cause=${e.cause}" + } + throw e + } + val expected = offset.toLong() + if (receipt.bytesReceived != expected) { + throw LegacyDfuException.PacketReceiptMismatch(expected, receipt.bytesReceived) + } + bytesAtLastPrn = receipt.bytesReceived + packetsSincePrn = 0 + onProgress(offset.toFloat() / firmware.size) + } + } + } + Logger.d { "Legacy DFU: Streamed $offset/${firmware.size} bytes (lastPRN=$bytesAtLastPrn)" } + } finally { + watcher.cancel() + } + } + } + + // --------------------------------------------------------------------------- + // Abort & teardown + // --------------------------------------------------------------------------- + + /** + * Send `RESET` to the device, instructing it to discard any in-progress transfer and reboot. Best-effort — the + * device may disconnect before the write ACK lands; that's expected. + */ + override suspend fun abort() { + safeCatching { + writeControlPoint(byteArrayOf(LegacyDfuOpcode.RESET)) + Logger.i { "Legacy DFU: RESET sent." } + } + .onFailure { Logger.w(it) { "Legacy DFU: Failed to send RESET (device may already be disconnected)" } } + } + + override suspend fun close() { + safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "Legacy DFU: Error during disconnect" } } + transportScope.cancel() + } + + // --------------------------------------------------------------------------- + // Low-level GATT helpers + // --------------------------------------------------------------------------- + + private suspend fun writeControlPoint(payload: ByteArray) { + bleConnection.profile(LegacyDfuUuids.SERVICE) { service -> + val controlChar = service.characteristic(LegacyDfuUuids.CONTROL_POINT) + service.write(controlChar, payload, BleWriteType.WITH_RESPONSE) + } + } + + private suspend fun writePacket(payload: ByteArray) { + bleConnection.profile(LegacyDfuUuids.SERVICE) { service -> + val packetChar = service.characteristic(LEGACY_DFU_PACKET_UUID) + service.write(packetChar, payload, BleWriteType.WITHOUT_RESPONSE) + } + } + + /** Write [data] to the Packet char in 20-byte chunks. Legacy DFU bootloaders cap packet size at 20 bytes. */ + private suspend fun writePacketChunked(data: ByteArray) { + bleConnection.profile(LegacyDfuUuids.SERVICE) { service -> + val packetChar = service.characteristic(LEGACY_DFU_PACKET_UUID) + var pos = 0 + while (pos < data.size) { + val end = minOf(pos + LEGACY_DFU_PACKET_SIZE, data.size) + service.write(packetChar, data.copyOfRange(pos, end), BleWriteType.WITHOUT_RESPONSE) + pos = end + } + } + } + + private suspend fun awaitResponse(timeout: Duration): LegacyDfuResponse = try { + withTimeout(timeout) { + // Drain any stray PRNs that arrive before the response we want. + while (true) { + val r = notificationChannel.receive() + if (r !is LegacyDfuResponse.PacketReceipt) return@withTimeout r + } + @Suppress("UNREACHABLE_CODE") + error("unreachable") + } + } catch (_: TimeoutCancellationException) { + throw DfuException.Timeout("No response from Legacy DFU Control Point after $timeout") + } + + private suspend fun awaitPacketReceipt(): LegacyDfuResponse.PacketReceipt = try { + withTimeout(COMMAND_TIMEOUT) { + while (true) { + val r = notificationChannel.receive() + if (r is LegacyDfuResponse.PacketReceipt) return@withTimeout r + if (r is LegacyDfuResponse.Failure) { + throw LegacyDfuException.ProtocolError(r.requestOpcode, r.status) + } + // Stray Success or Unknown → ignore. + } + @Suppress("UNREACHABLE_CODE") + error("unreachable") + } + } catch (_: TimeoutCancellationException) { + throw DfuException.Timeout("No packet receipt notification after $COMMAND_TIMEOUT") + } + + private fun requireSuccess(expectedOpcode: Byte, response: LegacyDfuResponse) { + when (response) { + is LegacyDfuResponse.Success -> + if (response.requestOpcode != expectedOpcode) { + throw DfuException.TransferFailed( + "Legacy DFU response opcode mismatch: expected " + + "0x${expectedOpcode.toUByte().toString(16).padStart(2, '0')}, " + + "got 0x${response.requestOpcode.toUByte().toString(16).padStart(2, '0')}", + ) + } + is LegacyDfuResponse.Failure -> + throw LegacyDfuException.ProtocolError(response.requestOpcode, response.status) + else -> + throw DfuException.TransferFailed( + "Unexpected Legacy DFU response for opcode 0x${expectedOpcode.toUByte().toString(16)}: $response", + ) + } + } + + // --------------------------------------------------------------------------- + // Scanning + // --------------------------------------------------------------------------- + + private suspend fun scanForDevice(predicate: (BleDevice) -> Boolean): BleDevice? = scanForBleDevice( + scanner = scanner, + tag = "Legacy DFU", + serviceUuid = LegacyDfuUuids.SERVICE, + retryCount = SCAN_RETRY_COUNT, + retryDelay = SCAN_RETRY_DELAY, + predicate = predicate, + ) + + // --------------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------------- + + companion object { + private val CONNECT_TIMEOUT = 15.seconds + private val COMMAND_TIMEOUT = 30.seconds + private val START_RESPONSE_TIMEOUT = 30.seconds + private val VALIDATE_TIMEOUT = 60.seconds + private val SUBSCRIPTION_SETTLE = 500.milliseconds + private const val SCAN_RETRY_COUNT = 3 + private val SCAN_RETRY_DELAY = 2.seconds + + /** + * Wall-clock budget for a full firmware streaming session. Must comfortably exceed the upload duration for the + * largest expected image at the slowest realistic Legacy DFU throughput (~1-3 KB/s with 20-byte packets). The + * per-receipt and per-write watchdogs inside the loop catch real stalls; this cap is just a safety net so a + * hung profile block can't sit forever. + */ + private val STREAM_TIMEOUT = 15.minutes + + /** + * Packet-receipt-notification interval (packets between flow-control ACKs). Higher values mean fewer + * notification round-trips per byte and therefore faster throughput, at the cost of a slightly longer recovery + * window if a packet is dropped (we have to wait until the next PRN boundary to detect the gap). + * + * Set to 30 per the explicit recommendation from the Adafruit OTAFIX bootloader maintainer + * (https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX#recommended-ota-dfu-settings — "Number of + * packets: 30"), which is the bootloader Meshtastic nRF52 devices ship with. Nordic's reference library + * defaults to 12; values above ~60 are not recommended for any host. Empirically 30 yields ~3x the throughput + * of PRN=10 on RAK4631 / OTAFIX without provoking OPERATION_FAILED on the bootloader's flash-write path. + */ + internal const val PRN_INTERVAL_PACKETS = 30 + + /** + * Default Legacy DFU packet size (20 bytes — the original ATT_MTU minus the 3-byte ATT header). + * + * Stock Nordic / pre-OTAFIX-2.1 bootloaders only support this size; sending larger packets to those bootloaders + * overruns the flash-write buffer and silently bricks the device. For OTAFIX 2.1+ bootloaders (detected via + * `OTAFIX_NAME_SUFFIX`), [computeStreamPacketSize] bumps the per-packet size up to the negotiated MTU (capped + * by [MAX_HIGH_MTU_PACKET_SIZE]) for a ~12× throughput win. + */ + internal const val LEGACY_DFU_PACKET_SIZE = 20 + + /** + * Cap on the high-MTU packet size used when an OTAFIX-2.1+ bootloader is detected. The largest ATT MTU the BLE + * 5.0 LE Data Length extension can give us is 247 bytes (244 of payload), and Adafruit's own flash accumulation + * buffer is 240 bytes, so capping at 244 keeps each write to one ATT PDU and one flash-write boundary. + */ + internal const val MAX_HIGH_MTU_PACKET_SIZE = 244 + + /** + * Suffix used by the OTAFIX 2.1+ bootloader on every supported board's DFU-mode advertising name (e.g. + * `4631_DFU`, `T114_DFU`, `XIAO_DFU`). The 2.0 release advertised generic `AdaDFU`/board names without this + * suffix, so its presence is a reliable in-band signal that high-MTU is supported. + */ + internal const val OTAFIX_NAME_SUFFIX = "_DFU" + + /** Minimum DFU Version we support; older bootloaders use the SDK ≤ 6 single-shot init flow. */ + private const val MIN_SUPPORTED_DFU_VERSION = 5 + + /** + * Init packets larger than this are almost certainly Secure-DFU shaped (signed CBOR ≈ 100-300 bytes) rather + * than legacy (14 B SDK 7 / 32 B SDK 11). 256 leaves comfortable headroom while still catching the obvious + * misuse case where a Secure `.dat` is fed into the Legacy path. + */ + internal const val MAX_REASONABLE_LEGACY_INIT_SIZE = 256 + } +} diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index a2eb5a7a4..ae88e9645 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -50,7 +50,10 @@ import org.meshtastic.feature.firmware.FirmwareUpdateHandler import org.meshtastic.feature.firmware.FirmwareUpdateState import org.meshtastic.feature.firmware.ProgressState import org.meshtastic.feature.firmware.ota.ThroughputTracker +import org.meshtastic.feature.firmware.ota.calculateMacPlusOne +import org.meshtastic.feature.firmware.ota.scanForBleDevice import org.meshtastic.feature.firmware.stripFormatArgs +import kotlin.time.Duration.Companion.seconds private const val PERCENT_MAX = 100 private const val GATT_RELEASE_DELAY_MS = 1_500L @@ -60,7 +63,11 @@ private const val CONNECT_ATTEMPTS = 4 private const val KIB_DIVISOR = 1024f /** - * KMP [FirmwareUpdateHandler] for nRF52 devices using the Nordic Secure DFU protocol over Kable BLE. + * KMP [FirmwareUpdateHandler] for nRF52 devices. + * + * Despite its historical name, this handler now drives **both** Nordic Secure DFU (service `FE59`) and Nordic Legacy + * DFU / Adafruit `BLEDfu` (service `1530`). After triggering the buttonless reboot it sniffs which DFU service the + * bootloader exposes and dispatches to the matching [DfuUploadTransport] implementation. * * All platform I/O (zip extraction, file reading) is delegated to [FirmwareFileHandler]. */ @@ -107,28 +114,47 @@ class SecureDfuHandler( radioController.setDeviceAddress("n") delay(GATT_RELEASE_DELAY_MS) - var transport: SecureDfuTransport? = null - var completed = false + // The trigger always uses SecureDfuTransport — it speaks both Secure (FE59) and Legacy (1530) + // buttonless triggers and falls back automatically (commit f26f610c0). + val triggerTransport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) try { - transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) - - transport.triggerButtonlessDfu().onFailure { e -> + triggerTransport.triggerButtonlessDfu().onFailure { e -> Logger.w(e) { "DFU: Buttonless trigger failed ($e) — device may already be in DFU mode" } } - delay(DFU_REBOOT_WAIT_MS) + } finally { + withContext(NonCancellable) { triggerTransport.close() } + } + delay(DFU_REBOOT_WAIT_MS) - // ── 4. Connect to device in DFU mode ───────────────────────────── + // ── 4. Service detection: which DFU protocol does the bootloader speak? ─ + val protocol = detectBootloaderProtocol(target, updateState) + Logger.i { "DFU: Bootloader protocol detected: $protocol" } + val transport: DfuUploadTransport = + when (protocol) { + DfuProtocolKind.LEGACY -> + LegacyDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) + DfuProtocolKind.SECURE -> + SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) + } + + var completed = false + try { + // ── 5. Connect to device in DFU mode ───────────────────────────── if (!connectWithRetry(transport, updateState)) return@withContext null - // ── 5. Init packet ──────────────────────────────────────────── + // ── 6. Init packet ──────────────────────────────────────────── updateState( FirmwareUpdateState.Processing( ProgressState(UiText.Resource(Res.string.firmware_update_starting_dfu)), ), ) + Logger.i { + "DFU: Sending init packet (${pkg.initPacket.size} bytes) and firmware " + + "(${pkg.firmware.size} bytes) via $protocol" + } transport.transferInitPacket(pkg.initPacket).getOrThrow() - // ── 6. Firmware ─────────────────────────────────────────────── + // ── 7. Firmware ─────────────────────────────────────────────── val uploadMsg = UiText.Resource(Res.string.firmware_update_uploading) updateState(FirmwareUpdateState.Updating(ProgressState(uploadMsg, 0f))) @@ -160,7 +186,7 @@ class SecureDfuHandler( } .getOrThrow() - // ── 7. Validate ─────────────────────────────────────────────── + // ── 8. Validate ─────────────────────────────────────────────── updateState( FirmwareUpdateState.Processing( ProgressState(UiText.Resource(Res.string.firmware_update_validating)), @@ -174,8 +200,8 @@ class SecureDfuHandler( // Send ABORT if cancelled mid-transfer, then always clean up. // NonCancellable ensures this runs even when the coroutine is being cancelled. withContext(NonCancellable) { - if (!completed) transport?.abort() - transport?.close() + if (!completed) transport.abort() + transport.close() } } } @@ -198,8 +224,35 @@ class SecureDfuHandler( // ── Helpers ────────────────────────────────────────────────────────────── + /** + * Detect which DFU protocol the bootloader speaks by scanning for advertised service UUIDs. We scan for the legacy + * service (1530) first with a short timeout — Adafruit/oltaco bootloaders always advertise it, while Nordic Secure + * bootloaders never do, so a hit unambiguously means Legacy. Miss ⇒ assume Secure (preserves current behavior on + * unaffected devices). + */ + private suspend fun detectBootloaderProtocol( + target: String, + updateState: (FirmwareUpdateState) -> Unit, + ): DfuProtocolKind { + updateState( + FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))), + ) + val targetAddresses = setOf(target, calculateMacPlusOne(target)) + val legacyHit = + scanForBleDevice( + scanner = bleScanner, + tag = "DFU detect", + serviceUuid = LegacyDfuUuids.SERVICE, + retryCount = 1, + retryDelay = 0.seconds, + scanTimeout = DETECT_SCAN_TIMEOUT, + predicate = { it.address in targetAddresses }, + ) + return if (legacyHit != null) DfuProtocolKind.LEGACY else DfuProtocolKind.SECURE + } + private suspend fun connectWithRetry( - transport: SecureDfuTransport, + transport: DfuUploadTransport, updateState: (FirmwareUpdateState) -> Unit, ): Boolean { updateState( @@ -260,4 +313,15 @@ class SecureDfuHandler( } return path } + + /** Result of [detectBootloaderProtocol]. */ + internal enum class DfuProtocolKind { + SECURE, + LEGACY, + } + + private companion object { + /** Detection scan timeout — short because we only want to confirm/refute an advertised legacy service. */ + private val DETECT_SCAN_TIMEOUT = 8.seconds + } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt index 4dbeba18a..b90910015 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuProtocol.kt @@ -43,6 +43,35 @@ internal object SecureDfuUuids { val BUTTONLESS_WITH_BONDS: Uuid = Uuid.parse("8EC90004-F315-4F60-9FB8-838830DAEA50") } +/** + * Nordic Legacy DFU service UUIDs (also used by Adafruit's `BLEDfu` helper class). Meshtastic firmware exposes this + * service when built **without** `BLE_DFU_SECURE`. The buttonless trigger is a single write of `0x01` (`START_DFU`) to + * the Control Point characteristic; the device then disconnects and reboots into the bootloader (which itself runs + * Secure DFU on modern Adafruit/oltaco bootloaders). + * + * Reference: `Adafruit_nRF52_Arduino/libraries/Bluefruit52Lib/src/services/BLEDfu.cpp`. + */ +internal object LegacyDfuUuids { + /** Legacy DFU service — exposed by app firmware to trigger reboot into the bootloader. */ + val SERVICE: Uuid = Uuid.parse("00001530-1212-EFDE-1523-785FEABCD123") + + /** + * Control Point: NOTIFY + WRITE. Notifications must be subscribed before writing or the device returns + * `ATTERR_CPS_CCCD_CONFIG_ERROR`. + */ + val CONTROL_POINT: Uuid = Uuid.parse("00001531-1212-EFDE-1523-785FEABCD123") +} + +/** Secure DFU buttonless trigger: single-byte `0x01` (START_DFU) to FE59 service. */ +internal const val BUTTONLESS_ENTER_BOOTLOADER: Byte = 0x01 + +/** + * Legacy DFU buttonless trigger payload per Nordic's `LegacyButtonlessDfuImpl.java:53`: `[OP_CODE_START_DFU=0x01, + * IMAGE_TYPE_APPLICATION=0x04]`. The Adafruit `BLEDfu` (and original Nordic SDK 6.x bootloader) require both bytes — + * sending only the opcode is a spec violation that some bootloader builds silently drop. + */ +internal val LEGACY_BUTTONLESS_ENTER_BOOTLOADER: ByteArray = byteArrayOf(0x01, 0x04) + // --------------------------------------------------------------------------- // Protocol opcodes // --------------------------------------------------------------------------- diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index 42e92c8ac..c9ebac27c 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -34,10 +34,13 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleConnectionState @@ -45,12 +48,15 @@ import org.meshtastic.core.ble.BleDevice import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH +import org.meshtastic.core.ble.MeshtasticBleDevice import org.meshtastic.core.common.util.safeCatching import org.meshtastic.feature.firmware.ota.calculateMacPlusOne import org.meshtastic.feature.firmware.ota.scanForBleDevice import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.Uuid /** * Kable-based transport for the Nordic Secure DFU (Secure DFU over BLE) protocol. @@ -67,7 +73,7 @@ class SecureDfuTransport( connectionFactory: BleConnectionFactory, private val address: String, dispatcher: CoroutineDispatcher, -) { +) : DfuUploadTransport { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) private val bleConnection = connectionFactory.create(transportScope, "Secure DFU") @@ -92,51 +98,21 @@ class SecureDfuTransport( * The caller must have already released the mesh-service BLE connection before calling this. */ suspend fun triggerButtonlessDfu(): Result = safeCatching { - Logger.i { "DFU: Scanning for device $address to trigger buttonless DFU..." } - - val device = - scanForDevice { d -> d.address == address } - ?: throw DfuException.ConnectionFailed("Device $address not found for buttonless DFU trigger") - + // No scan needed: the address is already bonded with the OS, so we can connect directly the same way the + // Nordic Android DFU library does (BluetoothAdapter.getRemoteDevice(address).connectGatt). Scanning here is + // unreliable because the device may not have resumed advertising in the brief window after we released the + // mesh-service GATT. Logger.i { "DFU: Connecting to $address to trigger buttonless DFU..." } - bleConnection.connectAndAwait(device, CONNECT_TIMEOUT) + bleConnection.connectAndAwait(MeshtasticBleDevice(address), CONNECT_TIMEOUT) - bleConnection.profile(SecureDfuUuids.SERVICE) { service -> - val buttonlessChar = service.characteristic(SecureDfuUuids.BUTTONLESS_NO_BONDS) - - // Enable indications by subscribing to the characteristic. The device-side firmware (BLEDfuSecure.cpp) - // checks that the CCCD is configured and returns ATTERR_CPS_CCCD_CONFIG_ERROR if not. - val indicationChannel = Channel(Channel.UNLIMITED) - val indicationJob = - service - .observe(buttonlessChar) - .onEach { indicationChannel.trySend(it) } - .catch { e -> Logger.d(e) { "DFU: Buttonless indication stream ended (expected on disconnect)" } } - .launchIn(this) - - delay(SUBSCRIPTION_SETTLE) - - Logger.i { "DFU: Writing buttonless DFU trigger..." } - service.write(buttonlessChar, byteArrayOf(0x01), BleWriteType.WITH_RESPONSE) - - // Wait for the indication response (0x20-01-STATUS). The device may disconnect before we receive it — - // that's expected and treated as success, matching the Nordic DFU library's behavior. - try { - withTimeout(BUTTONLESS_RESPONSE_TIMEOUT) { - val response = indicationChannel.receive() - if (response.size >= 3 && response[0] == BUTTONLESS_RESPONSE_CODE && response[2] != 0x01.toByte()) { - Logger.w { "DFU: Buttonless DFU response indicates error: ${response.toHexString()}" } - } else { - Logger.i { "DFU: Buttonless DFU indication received successfully" } - } - } - } catch (_: TimeoutCancellationException) { - Logger.d { "DFU: No buttonless indication received (device may have already disconnected)" } - } catch (_: Exception) { - Logger.d { "DFU: Buttonless indication wait interrupted (device disconnecting)" } - } - - indicationJob.cancel() + // Try the Nordic Secure DFU service (FE59) first — used when the firmware is built with BLE_DFU_SECURE. + // If it isn't exposed, fall back to the Legacy DFU service (1530) used by Meshtastic builds that rely on + // Adafruit's stock BLEDfu helper. Both ultimately reboot the device into the same bootloader. + try { + triggerSecureButtonless() + } catch (e: NoSuchElementException) { + Logger.i(e) { "DFU: Secure DFU service (FE59) not present — falling back to legacy DFU service (1530)" } + triggerLegacyButtonless() } // Device will disconnect and reboot — expected, not an error. @@ -144,6 +120,104 @@ class SecureDfuTransport( bleConnection.disconnect() } + /** Nordic Secure DFU buttonless trigger — INDICATE + write 0x01, then wait for `0x20-01-STATUS` response. */ + private suspend fun triggerSecureButtonless() { + triggerButtonless( + serviceUuid = SecureDfuUuids.SERVICE, + characteristicUuid = SecureDfuUuids.BUTTONLESS_NO_BONDS, + payload = byteArrayOf(BUTTONLESS_ENTER_BOOTLOADER), + profileTimeout = TRIGGER_TIMEOUT, + logLabel = "secure", + awaitResponse = { channel -> + try { + withTimeout(BUTTONLESS_RESPONSE_TIMEOUT) { + val response = channel.receive() + if ( + response.size >= 3 && + response[0] == BUTTONLESS_RESPONSE_CODE && + response[2] != 0x01.toByte() + ) { + Logger.w { "DFU: Buttonless DFU response indicates error: ${response.toHexString()}" } + } else { + Logger.i { "DFU: Buttonless DFU indication received successfully" } + } + } + } catch (_: TimeoutCancellationException) { + Logger.d { "DFU: No buttonless indication received (device may have already disconnected)" } + } catch (_: Exception) { + Logger.d { "DFU: Buttonless indication wait interrupted (device disconnecting)" } + } + }, + ) + } + + /** + * Nordic Legacy DFU buttonless trigger (Adafruit `BLEDfu`). Subscribe to NOTIFICATIONS on the control point + * (required to satisfy the device's CCCD check), then write `[0x01, 0x04]` (START_DFU + IMAGE_TYPE_APPLICATION, per + * Nordic's `LegacyButtonlessDfuImpl.java:53`). The device reboots into the bootloader immediately — no notification + * response is expected before disconnect. + */ + private suspend fun triggerLegacyButtonless() { + triggerButtonless( + serviceUuid = LegacyDfuUuids.SERVICE, + characteristicUuid = LegacyDfuUuids.CONTROL_POINT, + payload = LEGACY_BUTTONLESS_ENTER_BOOTLOADER, + profileTimeout = TRIGGER_TIMEOUT, + logLabel = "legacy", + awaitResponse = null, + ) + } + + /** + * Shared scaffold for both Secure and Legacy buttonless triggers. The Adafruit/Nordic protocol is identical in + * shape: enable CCCD on a control characteristic, write a small opcode, optionally await an indication/notification + * response. The whole trigger is wrapped in [profileTimeout] and treats timeout as success — by the time we'd time + * out, the device has either rebooted (verified by [connectToDfuMode]) or never received the byte (which surfaces + * as a useful scan failure later). This escapes the well-known race where Kable's `WITH_RESPONSE` write blocks on + * an ATT ACK that never arrives because the device rebooted before sending it. + */ + private suspend fun triggerButtonless( + serviceUuid: Uuid, + characteristicUuid: Uuid, + payload: ByteArray, + profileTimeout: Duration, + logLabel: String, + awaitResponse: (suspend CoroutineScope.(Channel) -> Unit)?, + ) { + try { + withTimeout(profileTimeout) { + bleConnection.profile(serviceUuid, timeout = profileTimeout) { service -> + val char = service.characteristic(characteristicUuid) + + val channel = Channel(Channel.UNLIMITED) + val observeJob = + service + .observe(char) + .onEach { channel.trySend(it) } + .catch { e -> + Logger.d(e) { "DFU: $logLabel notification stream ended (expected on disconnect)" } + } + .launchIn(this) + + delay(SUBSCRIPTION_SETTLE) + + Logger.i { "DFU: Writing $logLabel buttonless DFU trigger..." } + service.write(char, payload, BleWriteType.WITH_RESPONSE) + + awaitResponse?.invoke(this, channel) + + observeJob.cancel() + } + } + } catch (_: TimeoutCancellationException) { + Logger.w { + "DFU: $logLabel buttonless trigger timed out — likely cause: stale BLE bond (Meshtastic " + + "BLEDfu requires SECMODE_ENC_WITH_MITM). User must Forget+Re-pair the device in Android " + + "Bluetooth settings if the next DFU-mode scan also fails." + } + } + } + // --------------------------------------------------------------------------- // Phase 2: Connect to device in DFU mode // --------------------------------------------------------------------------- @@ -152,7 +226,7 @@ class SecureDfuTransport( * Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling * notifications on the Control Point. */ - suspend fun connectToDfuMode(): Result = safeCatching { + override suspend fun connectToDfuMode(): Result = safeCatching { val dfuAddress = calculateMacPlusOne(address) val targetAddresses = setOf(address, dfuAddress) Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." } @@ -210,7 +284,7 @@ class SecureDfuTransport( * PRN is explicitly disabled (set to 0) for the init packet per the Nordic DFU library convention — the init packet * is small (<512 bytes, fits in a single object) and does not benefit from flow control. */ - suspend fun transferInitPacket(initPacket: ByteArray): Result = safeCatching { + override suspend fun transferInitPacket(initPacket: ByteArray): Result = safeCatching { Logger.i { "DFU: Transferring init packet (${initPacket.size} bytes)..." } setPrn(0) transferObjectWithRetry(DfuObjectType.COMMAND, initPacket, onProgress = null) @@ -231,9 +305,13 @@ class SecureDfuTransport( * @param firmware Raw bytes of the `.bin` file. * @param onProgress Callback receiving progress in [0.0, 1.0]. */ - suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = + override suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = safeCatching { Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } + // Bump BLE link to high-throughput mode (~7.5 ms interval) before streaming. + // Default Android intervals (~30-50 ms) starve the link during sustained DFU and trigger LSTO. + val highPriorityRequested = bleConnection.requestHighConnectionPriority() + Logger.i { "Secure DFU: requestHighConnectionPriority -> $highPriorityRequested" } setPrn(PRN_INTERVAL) transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) Logger.i { "DFU: Firmware transferred and executed." } @@ -250,7 +328,7 @@ class SecureDfuTransport( * Call this before [close] when cancelling or recovering from an error so the device doesn't need a power cycle to * accept a fresh DFU session. */ - suspend fun abort() { + override suspend fun abort() { safeCatching { bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) @@ -262,7 +340,7 @@ class SecureDfuTransport( } /** Disconnect from the DFU target and cancel the transport coroutine scope. */ - suspend fun close() { + override suspend fun close() { safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } transportScope.cancel() } @@ -429,31 +507,41 @@ class SecureDfuTransport( */ private suspend fun writePackets(data: ByteArray, from: Int, until: Int, prnInterval: Int) { val mtu = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) ?: DEFAULT_BLE_WRITE_VALUE_LENGTH - var packetsSincePrn = 0 + var pos = from - bleConnection.profile(SecureDfuUuids.SERVICE) { service -> - val packetChar = service.characteristic(SecureDfuUuids.PACKET) - var pos = from + coroutineScope { + // Trip-wire: cancels the streaming coroutine the moment Kable observes a disconnect. + val watcher = launch { + val state = bleConnection.connectionState.first { it is BleConnectionState.Disconnected } + Logger.w { "Secure DFU: Link dropped mid-stream at offset $pos/$until (state=$state)" } + throw DfuException.ConnectionFailed("BLE link dropped mid-upload at byte $pos/$until") + } - while (pos < until) { - val chunkEnd = minOf(pos + mtu, until) - service.write(packetChar, data.copyOfRange(pos, chunkEnd), BleWriteType.WITHOUT_RESPONSE) - pos = chunkEnd - packetsSincePrn++ + try { + var packetsSincePrn = 0 + bleConnection.profile(SecureDfuUuids.SERVICE, timeout = STREAM_TIMEOUT) { service -> + val packetChar = service.characteristic(SecureDfuUuids.PACKET) + while (pos < until) { + val chunkEnd = minOf(pos + mtu, until) + service.write(packetChar, data.copyOfRange(pos, chunkEnd), BleWriteType.WITHOUT_RESPONSE) + pos = chunkEnd + packetsSincePrn++ - // Wait for the device's PRN receipt notification, then validate CRC. - // Skip the wait on the last packet — the final CALCULATE_CHECKSUM covers it. - if (prnInterval > 0 && packetsSincePrn >= prnInterval && pos < until) { - val response = awaitNotification(COMMAND_TIMEOUT) - if (response is DfuResponse.ChecksumResult) { - val expectedCrc = DfuCrc32.calculate(data, length = pos) - if (response.offset != pos || response.crc32 != expectedCrc) { - throw DfuException.ChecksumMismatch(expected = expectedCrc, actual = response.crc32) + if (prnInterval > 0 && packetsSincePrn >= prnInterval && pos < until) { + val response = awaitNotification(COMMAND_TIMEOUT) + if (response is DfuResponse.ChecksumResult) { + val expectedCrc = DfuCrc32.calculate(data, length = pos) + if (response.offset != pos || response.crc32 != expectedCrc) { + throw DfuException.ChecksumMismatch(expected = expectedCrc, actual = response.crc32) + } + Logger.d { "DFU: PRN checksum OK at offset $pos" } + } + packetsSincePrn = 0 } - Logger.d { "DFU: PRN checksum OK at offset $pos" } } - packetsSincePrn = 0 } + } finally { + watcher.cancel() } } } @@ -558,11 +646,28 @@ class SecureDfuTransport( private val COMMAND_TIMEOUT = 30.seconds private val SUBSCRIPTION_SETTLE = 500.milliseconds private val BUTTONLESS_RESPONSE_TIMEOUT = 3.seconds + + /** + * Tight wall-clock cap on the buttonless trigger phase (Secure or Legacy). After we send the opcode, the device + * reboots into the bootloader and disconnects — but Kable's `WITH_RESPONSE` write blocks on an ATT ACK that + * never arrives in this race. If we don't see the disconnect propagate within this window, the device almost + * certainly didn't receive the byte, so failing fast lets the caller surface a useful error (or retry) instead + * of waiting on the default 30s `profile()` timeout. Comfortably exceeds the secure-path indication wait + * ([BUTTONLESS_RESPONSE_TIMEOUT]) plus settle/write overhead. + */ + private val TRIGGER_TIMEOUT = 5.seconds private const val SCAN_RETRY_COUNT = 3 private val SCAN_RETRY_DELAY = 2.seconds private val RETRY_DELAY = 2.seconds private val FIRST_CHUNK_DELAY = 400.milliseconds + /** + * Wall-clock budget for a single firmware-object streaming session. Must comfortably exceed the upload duration + * for the largest expected image at the slowest realistic Secure DFU throughput. Per-PRN/per-write watchdogs + * inside the loop detect real stalls; this is just a safety net so a hung profile block can't sit forever. + */ + private val STREAM_TIMEOUT = 15.minutes + /** Response code prefix for Buttonless DFU indications (0x20 = response). */ private const val BUTTONLESS_RESPONSE_CODE: Byte = 0x20 diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocolTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocolTest.kt new file mode 100644 index 000000000..2b209402f --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuProtocolTest.kt @@ -0,0 +1,124 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.firmware.ota.dfu + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class LegacyDfuProtocolTest { + + @Test + fun `parse Success response`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x10, 0x01, 0x01)) + assertIs(r) + assertEquals(LegacyDfuOpcode.START_DFU, r.requestOpcode) + } + + @Test + fun `parse Failure response`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x10, 0x02, 0x06)) + assertIs(r) + assertEquals(LegacyDfuOpcode.INIT_DFU_PARAMS, r.requestOpcode) + assertEquals(LegacyDfuStatus.OPERATION_FAILED, r.status) + } + + @Test + fun `parse PacketReceipt - little endian uint32`() { + // 0x12345678 = 305419896 LE: 78 56 34 12 + val r = LegacyDfuResponse.parse(byteArrayOf(0x11, 0x78, 0x56, 0x34, 0x12)) + assertIs(r) + assertEquals(0x12345678L, r.bytesReceived) + } + + @Test + fun `parse PacketReceipt with high bit set treats value as unsigned`() { + // 0xFF000000 should be parsed as 4278190080 (positive long), not -16777216 (negative int). + val r = LegacyDfuResponse.parse(byteArrayOf(0x11, 0x00, 0x00, 0x00, 0xFF.toByte())) + assertIs(r) + assertEquals(0xFF000000L, r.bytesReceived) + } + + @Test + fun `parse Unknown for unrecognised prefix`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x42, 0x99.toByte())) + assertIs(r) + } + + @Test + fun `parse Unknown for empty bytes`() { + val r = LegacyDfuResponse.parse(byteArrayOf()) + assertIs(r) + } + + @Test + fun `parse Unknown for too-short response`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x10, 0x01)) + assertIs(r) + } + + @Test + fun `parse Unknown for too-short packet receipt`() { + val r = LegacyDfuResponse.parse(byteArrayOf(0x11, 0x01, 0x02)) + assertIs(r) + } + + @Test + fun `legacyImageSizesPayload is 12 bytes LE - app only`() { + // 0x1234 = 4660 → LE: 34 12 00 00 + val payload = legacyImageSizesPayload(appSize = 0x1234) + assertEquals(12, payload.size) + assertContentEquals( + byteArrayOf( + 0, + 0, + 0, + 0, // softdevice + 0, + 0, + 0, + 0, // bootloader + 0x34, + 0x12, + 0, + 0, // app + ), + payload, + ) + } + + @Test + fun `legacyImageSizesPayload with all three components`() { + val payload = legacyImageSizesPayload(appSize = 1, softDeviceSize = 2, bootloaderSize = 3) + assertContentEquals(byteArrayOf(2, 0, 0, 0, 3, 0, 0, 0, 1, 0, 0, 0), payload) + } + + @Test + fun `legacyPrnRequestPayload is opcode plus uint16 LE`() { + val payload = legacyPrnRequestPayload(packets = 10) + assertContentEquals(byteArrayOf(LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ, 10, 0), payload) + } + + @Test + fun `legacyPrnRequestPayload over 256 spans both bytes`() { + val payload = legacyPrnRequestPayload(packets = 0x1234) + assertContentEquals(byteArrayOf(LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ, 0x34, 0x12), payload) + } +} diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt new file mode 100644 index 000000000..4504e460d --- /dev/null +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt @@ -0,0 +1,535 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber", "LargeClass", "TooManyFunctions") + +package org.meshtastic.feature.firmware.ota.dfu + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.ble.BleCharacteristic +import org.meshtastic.core.ble.BleConnection +import org.meshtastic.core.ble.BleConnectionFactory +import org.meshtastic.core.ble.BleConnectionState +import org.meshtastic.core.ble.BleDevice +import org.meshtastic.core.ble.BleService +import org.meshtastic.core.ble.BleWriteType +import org.meshtastic.core.testing.FakeBleConnection +import org.meshtastic.core.testing.FakeBleConnectionFactory +import org.meshtastic.core.testing.FakeBleDevice +import org.meshtastic.core.testing.FakeBleScanner +import org.meshtastic.core.testing.FakeBleService +import org.meshtastic.core.testing.FakeBleWrite +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +@OptIn(ExperimentalCoroutinesApi::class) +class LegacyDfuTransportTest { + + private val address = "00:11:22:33:44:55" + private val dfuAddress = "00:11:22:33:44:56" + + // ----------------------------------------------------------------------- + // Phase 2: connectToDfuMode + // ----------------------------------------------------------------------- + + @Test + fun `connectToDfuMode succeeds when bootloader exposes 1530 service`() = runTest { + val scanner = FakeBleScanner() + val connection = FakeBleConnection() + val transport = + LegacyDfuTransport(scanner, FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined) + + scanner.emitDevice(FakeBleDevice(dfuAddress)) + + val result = transport.connectToDfuMode() + + assertTrue(result.isSuccess, "connectToDfuMode failed: ${result.exceptionOrNull()}") + } + + @Test + fun `connectToDfuMode fails fast on unsupported old DFU Version lt 5`() = runTest { + val scanner = FakeBleScanner() + val connection = FakeBleConnection() + // Pre-seed the version characteristic with a SDK 6 version (0x0004 LE). + connection.service.enqueueRead(LEGACY_DFU_VERSION_UUID, byteArrayOf(0x04, 0x00)) + val transport = + LegacyDfuTransport(scanner, FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined) + + scanner.emitDevice(FakeBleDevice(dfuAddress)) + + val result = transport.connectToDfuMode() + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `connectToDfuMode accepts modern DFU Version 8`() = runTest { + val scanner = FakeBleScanner() + val connection = FakeBleConnection() + connection.service.enqueueRead(LEGACY_DFU_VERSION_UUID, byteArrayOf(0x08, 0x00)) + val transport = + LegacyDfuTransport(scanner, FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined) + + scanner.emitDevice(FakeBleDevice(dfuAddress)) + + val result = transport.connectToDfuMode() + + assertTrue(result.isSuccess) + } + + @Test + fun `connectToDfuMode accepts missing DFU Version characteristic`() = runTest { + // Default FakeBleService.read returns empty bytes when nothing is enqueued — treated as "absent". + val scanner = FakeBleScanner() + val connection = FakeBleConnection() + val transport = + LegacyDfuTransport(scanner, FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined) + + scanner.emitDevice(FakeBleDevice(dfuAddress)) + + val result = transport.connectToDfuMode() + + assertTrue( + result.isSuccess, + "Missing DFU Version should be treated as modern (proceed): ${result.exceptionOrNull()}", + ) + } + + // ----------------------------------------------------------------------- + // Phase 3: transferInitPacket preflight + // ----------------------------------------------------------------------- + + @Test + fun `transferInitPacket rejects oversized init packet looks like Secure-shaped dat`() = runTest { + val transport = + LegacyDfuTransport( + FakeBleScanner(), + FakeBleConnectionFactory(FakeBleConnection()), + address, + Dispatchers.Unconfined, + ) + val oversized = ByteArray(LegacyDfuTransport.MAX_REASONABLE_LEGACY_INIT_SIZE + 1) { 0x42 } + + val result = transport.transferInitPacket(oversized) + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `transferInitPacket accepts typical 14 byte legacy init`() = runTest { + val transport = + LegacyDfuTransport( + FakeBleScanner(), + FakeBleConnectionFactory(FakeBleConnection()), + address, + Dispatchers.Unconfined, + ) + val init = ByteArray(14) { it.toByte() } + + val result = transport.transferInitPacket(init) + + assertTrue(result.isSuccess) + } + + // ----------------------------------------------------------------------- + // Phase 4: transferFirmware happy path + // ----------------------------------------------------------------------- + + @Test + fun `transferFirmware happy path writes correct opcode and packet sequence`() = runTest { + val env = createConnectedTransport() + env.responder.scheme = LegacyResponderScheme.HappyPath + + val init = ByteArray(14) { it.toByte() } + val firmware = ByteArray(80) { (0xA0 + it).toByte() } // 4 packets at MTU=20 + + env.transport.transferInitPacket(init).getOrThrow() + val progress = mutableListOf() + val result = env.transport.transferFirmware(firmware) { progress.add(it) } + + assertTrue(result.isSuccess, "transferFirmware failed: ${result.exceptionOrNull()}") + + // Control-point opcode order: + // START_DFU, INIT_DFU_PARAMS_START, INIT_DFU_PARAMS_COMPLETE, + // PACKET_RECEIPT_NOTIF_REQ, RECEIVE_FIRMWARE_IMAGE, VALIDATE, ACTIVATE_AND_RESET + val cpOpcodes = env.controlPointWrites().map { it.data[0] } + assertEquals( + listOf( + LegacyDfuOpcode.START_DFU, + LegacyDfuOpcode.INIT_DFU_PARAMS, + LegacyDfuOpcode.INIT_DFU_PARAMS, + LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ, + LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE, + LegacyDfuOpcode.VALIDATE, + LegacyDfuOpcode.ACTIVATE_AND_RESET, + ), + cpOpcodes, + ) + + // First INIT_DFU_PARAMS sub-opcode is START (0x00), second is COMPLETE (0x01). + val initParamsWrites = env.controlPointWrites().filter { it.data[0] == LegacyDfuOpcode.INIT_DFU_PARAMS } + assertEquals(LegacyDfuOpcode.INIT_PARAMS_START, initParamsWrites[0].data[1]) + assertEquals(LegacyDfuOpcode.INIT_PARAMS_COMPLETE, initParamsWrites[1].data[1]) + + // START_DFU includes APP image type byte. + val startWrite = env.controlPointWrites().single { it.data[0] == LegacyDfuOpcode.START_DFU } + assertContentEquals(byteArrayOf(LegacyDfuOpcode.START_DFU, LegacyDfuImageType.APPLICATION), startWrite.data) + + // Packet writes should contain: [12B image sizes] then [14B init in chunks] then [firmware in chunks]. + val packetBytes = env.packetWrites().flatMap { it.data.toList() }.toByteArray() + // Image sizes payload (first 12 bytes): app size = firmware.size, others 0. + val imageSizes = packetBytes.copyOfRange(0, 12) + assertContentEquals(legacyImageSizesPayload(appSize = firmware.size), imageSizes) + // Init follows. + assertContentEquals(init, packetBytes.copyOfRange(12, 12 + init.size)) + // Firmware follows. + assertContentEquals(firmware, packetBytes.copyOfRange(12 + init.size, packetBytes.size)) + + // Final progress should be 1.0. + assertEquals(1f, progress.last()) + + // Packet writes use WITHOUT_RESPONSE. + env.packetWrites().forEach { assertEquals(BleWriteType.WITHOUT_RESPONSE, it.writeType) } + } + + // ----------------------------------------------------------------------- + // Phase 4: error paths + // ----------------------------------------------------------------------- + + @Test + fun `transferFirmware fails with ProtocolError when device rejects START_DFU`() = runTest { + val env = createConnectedTransport() + env.responder.scheme = LegacyResponderScheme.RejectStart + + env.transport.transferInitPacket(ByteArray(14)).getOrThrow() + val result = env.transport.transferFirmware(ByteArray(40)) {} + + assertTrue(result.isFailure) + val ex = assertIs(result.exceptionOrNull()) + assertEquals(LegacyDfuOpcode.START_DFU, ex.requestOpcode) + assertEquals(LegacyDfuStatus.NOT_SUPPORTED, ex.status) + } + + @Test + fun `transferFirmware fails with ProtocolError when device rejects INIT_DFU_PARAMS`() = runTest { + val env = createConnectedTransport() + env.responder.scheme = LegacyResponderScheme.RejectInit + + env.transport.transferInitPacket(ByteArray(14)).getOrThrow() + val result = env.transport.transferFirmware(ByteArray(40)) {} + + assertTrue(result.isFailure) + val ex = assertIs(result.exceptionOrNull()) + assertEquals(LegacyDfuOpcode.INIT_DFU_PARAMS, ex.requestOpcode) + assertEquals(LegacyDfuStatus.OPERATION_FAILED, ex.status) + } + + @Test + fun `transferFirmware fails with ProtocolError when device rejects VALIDATE`() = runTest { + val env = createConnectedTransport() + env.responder.scheme = LegacyResponderScheme.RejectValidate + + env.transport.transferInitPacket(ByteArray(14)).getOrThrow() + val result = env.transport.transferFirmware(ByteArray(40)) {} + + assertTrue(result.isFailure) + val ex = assertIs(result.exceptionOrNull()) + assertEquals(LegacyDfuOpcode.VALIDATE, ex.requestOpcode) + assertEquals(LegacyDfuStatus.CRC_ERROR, ex.status) + } + + @Test + fun `transferFirmware fails with PacketReceiptMismatch when device under-reports`() = runTest { + val env = createConnectedTransport() + env.responder.scheme = LegacyResponderScheme.PrnUnderReport + + env.transport.transferInitPacket(ByteArray(14)).getOrThrow() + // Send (PRN_INTERVAL_PACKETS + 1) packets of 20 bytes — guarantees a PRN window fires + // before the firmware completes, so the under-reported byte count surfaces as a mismatch. + val firmwareSize = (LegacyDfuTransport.PRN_INTERVAL_PACKETS + 1) * 20 + val result = env.transport.transferFirmware(ByteArray(firmwareSize)) {} + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `transferFirmware tolerates ACTIVATE_AND_RESET write failure - disconnect race`() = runTest { + val env = createConnectedTransport() + env.responder.scheme = LegacyResponderScheme.HappyPath + // After VALIDATE response is sent, ACTIVATE write should be treated as success even if the device throws. + env.responder.failOnActivateWrite = true + + env.transport.transferInitPacket(ByteArray(14)).getOrThrow() + val result = env.transport.transferFirmware(ByteArray(40)) {} + + assertTrue( + result.isSuccess, + "ACTIVATE write failure must be treated as success; got: ${result.exceptionOrNull()}", + ) + } + + // ----------------------------------------------------------------------- + // Abort + // ----------------------------------------------------------------------- + + @Test + fun `abort writes RESET opcode through control point`() = runTest { + val connection = FakeBleConnection() + val transport = + LegacyDfuTransport(FakeBleScanner(), FakeBleConnectionFactory(connection), address, Dispatchers.Unconfined) + + transport.abort() + + val write = connection.service.writes.single() + assertEquals(LegacyDfuUuids.CONTROL_POINT, write.characteristic.uuid) + assertContentEquals(byteArrayOf(LegacyDfuOpcode.RESET), write.data) + assertEquals(BleWriteType.WITH_RESPONSE, write.writeType) + } + + // ----------------------------------------------------------------------- + // Test infrastructure + // ----------------------------------------------------------------------- + + private class TestEnv(val transport: LegacyDfuTransport, val service: AutoRespondingLegacyService) { + val responder = service.responder + + fun controlPointWrites(): List = + service.delegate.writes.filter { it.characteristic.uuid == LegacyDfuUuids.CONTROL_POINT } + + fun packetWrites(): List = + service.delegate.writes.filter { it.characteristic.uuid == LEGACY_DFU_PACKET_UUID } + } + + private suspend fun createConnectedTransport(mtu: Int? = null): TestEnv { + val scanner = FakeBleScanner() + val fakeConnection = FakeBleConnection().apply { maxWriteValueLength = mtu } + val autoService = AutoRespondingLegacyService(fakeConnection.service) + val wrappedConnection = AutoRespondingBleConnection(fakeConnection, autoService) + val factory = + object : BleConnectionFactory { + override fun create(scope: CoroutineScope, tag: String): BleConnection = wrappedConnection + } + val transport = LegacyDfuTransport(scanner, factory, address, Dispatchers.Unconfined) + + scanner.emitDevice(FakeBleDevice(dfuAddress)) + transport.connectToDfuMode().getOrThrow() + return TestEnv(transport, autoService) + } + + /** + * Drives the simulated bootloader response stream. After each `write()` to control point or packet, this service + * synthesises the appropriate notification(s) on the control-point characteristic so the transport's pending + * `awaitResponse` / `awaitPacketReceipt` calls unblock. + */ + private class AutoRespondingLegacyService(val delegate: FakeBleService) : BleService { + val responder = LegacyResponder() + + override fun hasCharacteristic(c: BleCharacteristic) = delegate.hasCharacteristic(c) + + override fun observe(c: BleCharacteristic): Flow = delegate.observe(c) + + override suspend fun read(c: BleCharacteristic): ByteArray = delegate.read(c) + + override fun preferredWriteType(c: BleCharacteristic): BleWriteType = delegate.preferredWriteType(c) + + override suspend fun write(c: BleCharacteristic, data: ByteArray, writeType: BleWriteType) { + delegate.write(c, data, writeType) + val response = responder.onWrite(c.uuid, data) ?: return + response.forEach { delegate.emitNotification(LegacyDfuUuids.CONTROL_POINT, it) } + } + } + + /** What the simulated bootloader is meant to do for this test case. */ + enum class LegacyResponderScheme { + HappyPath, + RejectStart, + RejectInit, + RejectValidate, + PrnUnderReport, + } + + /** + * Synthesises Legacy DFU notifications based on the transport's current write. Behaviour depends on [scheme]: + * - `HappyPath`: returns Success for every control-point opcode that expects a response, plus accurate PRN receipts + * as packets accumulate. + * - Reject* variants: return Failure with the indicated status for the targeted opcode. + * - `PrnUnderReport`: at the first PRN window, report bytesReceived = actual − 1 to trigger PacketReceiptMismatch. + * + * Image-sizes write (12B on packet) is the trigger for the START_DFU response — matching the real protocol where + * the device responds *after* it sees both the opcode and the size payload. + */ + class LegacyResponder { + var scheme: LegacyResponderScheme = LegacyResponderScheme.HappyPath + var failOnActivateWrite: Boolean = false + + private var packetBytesReceived = 0L + private var packetsSinceLastPrn = 0 + private var firmwareTransferStarted = false + private var imageSizesWritten = false + private var expectedFirmwareSize: Int = 0 + + fun onWrite(uuid: kotlin.uuid.Uuid, data: ByteArray): List? = when (uuid) { + LegacyDfuUuids.CONTROL_POINT -> handleControlWrite(data) + LEGACY_DFU_PACKET_UUID -> handlePacketWrite(data) + else -> null + } + + @Suppress("ReturnCount") + private fun handleControlWrite(data: ByteArray): List? { + if (data.isEmpty()) return null + val opcode = data[0] + return when (opcode) { + LegacyDfuOpcode.START_DFU -> null // response comes after image sizes packet write + LegacyDfuOpcode.INIT_DFU_PARAMS -> { + if (data.size >= 2 && data[1] == LegacyDfuOpcode.INIT_PARAMS_COMPLETE) { + listOf(initResponse()) + } else { + null + } + } + LegacyDfuOpcode.PACKET_RECEIPT_NOTIF_REQ -> null + LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE -> { + firmwareTransferStarted = true + null + } + LegacyDfuOpcode.VALIDATE -> listOf(validateResponse()) + LegacyDfuOpcode.ACTIVATE_AND_RESET -> { + if (failOnActivateWrite) { + throw RuntimeException("Simulated disconnect during ACTIVATE write") + } + null + } + LegacyDfuOpcode.RESET -> null + else -> null + } + } + + private fun handlePacketWrite(data: ByteArray): List? { + // First packet write is the 12-byte image sizes payload (after START_DFU). + if (!imageSizesWritten) { + imageSizesWritten = true + // Parse appSize from bytes 8..11 (LE u32). + if (data.size >= 12) { + expectedFirmwareSize = + (data[8].toInt() and 0xFF) or + ((data[9].toInt() and 0xFF) shl 8) or + ((data[10].toInt() and 0xFF) shl 16) or + ((data[11].toInt() and 0xFF) shl 24) + } + return listOf(startResponse()) + } + + if (firmwareTransferStarted) { + packetBytesReceived += data.size + packetsSinceLastPrn++ + val responses = mutableListOf() + val firmwareDone = packetBytesReceived >= expectedFirmwareSize + if (packetsSinceLastPrn >= LegacyDfuTransport.PRN_INTERVAL_PACKETS && !firmwareDone) { + packetsSinceLastPrn = 0 + val reported = + if (scheme == LegacyResponderScheme.PrnUnderReport) { + packetBytesReceived - 1 + } else { + packetBytesReceived + } + responses += packetReceipt(reported) + } + if (firmwareDone) { + responses += success(LegacyDfuOpcode.RECEIVE_FIRMWARE_IMAGE) + } + return responses.takeIf { it.isNotEmpty() } + } + // Init-packet writes between START and COMPLETE: silent. + return null + } + + private fun startResponse(): ByteArray = when (scheme) { + LegacyResponderScheme.RejectStart -> + byteArrayOf(LegacyDfuOpcode.RESPONSE_CODE, LegacyDfuOpcode.START_DFU, LegacyDfuStatus.NOT_SUPPORTED) + else -> success(LegacyDfuOpcode.START_DFU) + } + + private fun initResponse(): ByteArray = when (scheme) { + LegacyResponderScheme.RejectInit -> + byteArrayOf( + LegacyDfuOpcode.RESPONSE_CODE, + LegacyDfuOpcode.INIT_DFU_PARAMS, + LegacyDfuStatus.OPERATION_FAILED, + ) + else -> success(LegacyDfuOpcode.INIT_DFU_PARAMS) + } + + private fun validateResponse(): ByteArray = when (scheme) { + LegacyResponderScheme.RejectValidate -> + byteArrayOf(LegacyDfuOpcode.RESPONSE_CODE, LegacyDfuOpcode.VALIDATE, LegacyDfuStatus.CRC_ERROR) + else -> success(LegacyDfuOpcode.VALIDATE) + } + + private fun packetReceipt(bytesReceived: Long): ByteArray = byteArrayOf( + LegacyDfuOpcode.PACKET_RECEIPT, + (bytesReceived and 0xFF).toByte(), + ((bytesReceived ushr 8) and 0xFF).toByte(), + ((bytesReceived ushr 16) and 0xFF).toByte(), + ((bytesReceived ushr 24) and 0xFF).toByte(), + ) + + private fun success(opcode: Byte): ByteArray = + byteArrayOf(LegacyDfuOpcode.RESPONSE_CODE, opcode, LegacyDfuStatus.SUCCESS) + } + + /** BleConnection wrapper that swaps in the auto-responding service for `profile()` calls. */ + private class AutoRespondingBleConnection( + private val delegate: FakeBleConnection, + val autoService: AutoRespondingLegacyService, + ) : BleConnection { + override val device: BleDevice? + get() = delegate.device + + override val deviceFlow: SharedFlow + get() = delegate.deviceFlow + + override val connectionState: SharedFlow + get() = delegate.connectionState + + override suspend fun connect(device: BleDevice) = delegate.connect(device) + + override suspend fun connectAndAwait(device: BleDevice, timeout: Duration) = + delegate.connectAndAwait(device, timeout) + + override suspend fun disconnect() = delegate.disconnect() + + override suspend fun profile( + serviceUuid: kotlin.uuid.Uuid, + timeout: Duration, + setup: suspend CoroutineScope.(BleService) -> T, + ): T = CoroutineScope(Dispatchers.Unconfined).setup(autoService) + + override fun maximumWriteValueLength(writeType: BleWriteType): Int? = + delegate.maximumWriteValueLength(writeType) + } +} diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt index da8f84057..454aadaa2 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt @@ -80,9 +80,35 @@ class SecureDfuTransportTest { assertEquals(1, connection.disconnectCalls) } - // ----------------------------------------------------------------------- - // Phase 2: Connect to DFU mode - // ----------------------------------------------------------------------- + @Test + fun `triggerButtonlessDfu falls back to legacy DFU service when secure FE59 is missing`() = runTest { + val scanner = FakeBleScanner() + val connection = FakeBleConnection().apply { missingServices += SecureDfuUuids.SERVICE } + val transport = + SecureDfuTransport( + scanner = scanner, + connectionFactory = FakeBleConnectionFactory(connection), + address = address, + dispatcher = kotlinx.coroutines.Dispatchers.Unconfined, + ) + + scanner.emitDevice(FakeBleDevice(address)) + + val result = transport.triggerButtonlessDfu() + + assertTrue(result.isSuccess, "Legacy fallback should succeed when FE59 is absent") + // No write should have hit the secure characteristic. + assertTrue( + connection.service.writes.none { it.characteristic.uuid == SecureDfuUuids.BUTTONLESS_NO_BONDS }, + "Should not write to secure buttonless characteristic when FE59 is missing", + ) + // Exactly one write of 0x01 (START_DFU) should have hit the legacy control point. + val legacyWrites = connection.service.writes.filter { it.characteristic.uuid == LegacyDfuUuids.CONTROL_POINT } + assertEquals(1, legacyWrites.size, "Should have exactly one legacy DFU trigger write") + assertContentEquals(byteArrayOf(0x01, 0x04), legacyWrites.single().data) + assertEquals(BleWriteType.WITH_RESPONSE, legacyWrites.single().writeType) + assertEquals(1, connection.disconnectCalls) + } @Test fun `connectToDfuMode succeeds using shared BleService observation`() = runTest { From b1d87e33335e282c36918f36bdae2b3c2f1fe99a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:32:07 -0500 Subject: [PATCH 37/65] chore(deps): update core/proto/src/main/proto digest to 97ea65a (#5212) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: James Rich Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../commonMain/kotlin/org/meshtastic/core/model/Channel.kt | 4 ++++ core/proto/src/main/proto | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt index 7e19e0295..c5cfc49ae 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt @@ -80,6 +80,10 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon ModemPreset.LONG_MODERATE -> "LongMod" ModemPreset.VERY_LONG_SLOW -> "VLongSlow" ModemPreset.LONG_TURBO -> "LongTurbo" + ModemPreset.LITE_FAST -> "LiteFast" + ModemPreset.LITE_SLOW -> "LiteSlow" + ModemPreset.NARROW_FAST -> "NarrowFast" + ModemPreset.NARROW_SLOW -> "NarrowSlow" } } else { "Custom" diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index d004f503b..97ea65a10 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit d004f503bbf3498fd689013a794e2a0e384b3f19 +Subproject commit 97ea65a10d31f24d84c8510342f2cd2d213c35a5 From 9b20d980efa4e746a41a85238c32211691bdc544 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:32:20 -0500 Subject: [PATCH 38/65] chore(deps): update ktor to v3.4.3 (#5214) 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 301531f82..45e7b4d71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,7 +59,7 @@ mlkit-barcode-scanning = "17.3.0" camerax = "1.6.0" # Networking -ktor = "3.4.2" +ktor = "3.4.3" # Other aboutlibraries = "14.0.1" From 0be5334cd9d5c2484dd38a4d110600c65d5a3242 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:32:35 -0500 Subject: [PATCH 39/65] chore(deps): update koin.plugin to v1.0.0-rc2 (#5213) 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 45e7b4d71..9ff0f8c3a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ navigation3 = "1.1.0" paging = "3.4.2" room = "3.0.0-alpha03" koin = "4.2.1" -koin-plugin = "1.0.0-RC1" +koin-plugin = "1.0.0-RC2" # Kotlin kotlin = "2.3.21-RC2" From 58eeef11527ace64049018ff7ed8b19b1ed57971 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:04:29 -0500 Subject: [PATCH 40/65] feat(service): send polite ToRadio(disconnect=true) before transport close (#5210) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/repository/RadioInterfaceService.kt | 7 ++++ .../core/service/MeshServiceOrchestrator.kt | 9 +++++ .../service/SharedRadioInterfaceService.kt | 40 +++++++++++++++++++ .../core/testing/FakeRadioInterfaceService.kt | 4 ++ .../org/meshtastic/desktop/stub/NoopStubs.kt | 4 ++ 5 files changed, 64 insertions(+) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index cbaf8b3dc..5ef6e1515 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -95,6 +95,13 @@ interface RadioInterfaceService : RadioTransportCallback { /** Initiates the connection to the radio. */ fun connect() + /** + * Explicitly tears down the active transport, sending a polite `ToRadio(disconnect = true)` goodbye frame first + * when a transport is live. Safe to call when nothing is connected — implementations must no-op in that case. + * Suspends until the teardown completes. + */ + suspend fun disconnect() + /** Returns the current device address. */ fun getDeviceAddress(): String? diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index ebac9f71b..66622c727 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch @@ -149,6 +150,14 @@ class MeshServiceOrchestrator( if (takServerManager.isRunning.value) { takMeshIntegration.stop() } + // Best-effort polite goodbye on service teardown (onDestroy / process shutdown). We launch + // on a fresh detached scope — not the orchestrator's per-start scope — so the subsequent + // scope.cancel() below doesn't interrupt the short drain delay inside disconnect(). The + // coroutine is fire-and-forget; typical runtime is ~100-150ms which comfortably fits + // inside Android's onDestroy() grace window. + CoroutineScope(SupervisorJob() + dispatchers.default).launch { + runCatching { radioInterfaceService.disconnect() } + } scope?.cancel() scope = null } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 1bb63971c..67db67d07 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -59,6 +59,7 @@ import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory +import org.meshtastic.proto.ToRadio import kotlin.concurrent.Volatile /** @@ -121,6 +122,13 @@ class SharedRadioInterfaceService( private var runningTransportId: InterfaceId? = null private var isStarted = false + /** + * Set while [stopTransportLocked] is draining the polite disconnect frame. [sendToRadio] checks this so any late + * traffic submitted after we've announced disconnection is dropped rather than racing in front of the firmware-side + * link teardown. + */ + @Volatile private var isStopping = false + private val listenersInitialized = atomic(false) private var heartbeatJob: Job? = null private var lastHeartbeatMillis = 0L @@ -135,6 +143,13 @@ class SharedRadioInterfaceService( // zombie (BLE stack didn't report disconnect). Two missed heartbeat intervals gives // the firmware a reasonable window to respond or send telemetry. private const val LIVENESS_TIMEOUT_MILLIS = HEARTBEAT_INTERVAL_MILLIS * 2 + + /** + * Upper bound on how long we wait for the polite `ToRadio(disconnect = true)` frame to flush before tearing the + * transport down. 500ms gives BLE's write-retry path (`BleRetry` backs off 500ms) room for one attempt on a + * flaky GATT connection. Serial and TCP typically flush well under this window. + */ + private const val POLITE_DISCONNECT_DRAIN_MS = 500L } private val initLock = Mutex() @@ -193,6 +208,10 @@ class SharedRadioInterfaceService( initStateListeners() } + override suspend fun disconnect() { + transportMutex.withLock { ignoreExceptionSuspend { stopTransportLocked() } } + } + override fun isMockTransport(): Boolean = transportFactory.isMockTransport() override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = @@ -257,9 +276,26 @@ class SharedRadioInterfaceService( private suspend fun stopTransportLocked() { val currentTransport = radioTransport Logger.i { "Stopping transport $currentTransport" } + // Best-effort polite goodbye: tell the firmware we're disconnecting on purpose so it can + // tear down its side of the link cleanly instead of relying on timeouts / hardware events. + // Flip isStopping before sending so any concurrent sendToRadio() drops incoming traffic — + // we don't want normal packets racing behind the disconnect frame. Skip only when already + // Disconnected; firmware can still consume the goodbye while handshaking or sleeping, so + // it's worth sending in every other state. The send is fire-and-forget through the + // transport's own scope; the drain delay gives async transports a window to flush before + // close() cancels their write scope. BLE's retry path backs off 500ms, so this window + // also covers one retry on flaky GATT links. + if (currentTransport != null && _connectionState.value != ConnectionState.Disconnected) { + isStopping = true + ignoreExceptionSuspend { + currentTransport.handleSendToRadio(ToRadio(disconnect = true).encode()) + delay(POLITE_DISCONNECT_DRAIN_MS) + } + } isStarted = false radioTransport = null runningTransportId = null + isStopping = false currentTransport?.close() _serviceScope.cancel("stopping transport") @@ -310,6 +346,10 @@ class SharedRadioInterfaceService( } override fun sendToRadio(bytes: ByteArray) { + if (isStopping) { + Logger.d { "sendToRadio: transport stopping, dropping ${bytes.size} bytes" } + return + } // Snapshot the transport to avoid calling handleSendToRadio on a null reference. // There is still a benign race: stopTransportLocked() may cancel _serviceScope // between the null-check and the launch, causing the coroutine to be silently diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt index d3f8dc71e..cd31a2e97 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt @@ -75,6 +75,10 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main connectCalled = true } + override suspend fun disconnect() { + connectCalled = false + } + override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value override fun setDeviceAddress(deviceAddr: String?): Boolean { 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 707dfaf03..36cd28507 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -89,6 +89,10 @@ class NoopRadioInterfaceService : RadioInterfaceService { logWarn("NoopRadioInterfaceService.connect()") } + override suspend fun disconnect() { + logWarn("NoopRadioInterfaceService.disconnect()") + } + override fun getDeviceAddress(): String? = null override fun setDeviceAddress(deviceAddr: String?): Boolean = false From ba559549baf62fe15a2c9747c89579bc51918535 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:04:57 -0500 Subject: [PATCH 41/65] refactor: eliminate Accompanist permissions library (#5211) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 5 +++ app/src/main/AndroidManifest.xml | 11 ++++- .../meshtastic/core/ui/util/PlatformUtils.kt | 30 ++++++++++++++ .../meshtastic/core/ui/util/PlatformUtils.kt | 10 +++++ .../org/meshtastic/core/ui/util/NoopStubs.kt | 5 +++ .../meshtastic/core/ui/util/PlatformUtils.kt | 9 ++++ .../feature/settings/tak/TakPermissionUtil.kt | 41 ++++++------------- 7 files changed, 81 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c1bafdd96..e22717f9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,11 @@ Do NOT duplicate content into agent-specific files. When you modify architecture - **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them. - **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules. - **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change. +- **Verify Before Push:** Treat any "push", "commit and push", or "push and pr" request as **verify-then-push**. Before `git push`, run `./gradlew spotlessApply detekt` (and the relevant `:module:test` / `:module:lintDebug` for touched modules). CI has repeatedly failed on `UnusedParameter`, `CyclomaticComplexMethod`, and `MagicNumber` from skipping this step. Only push on green; if a check fails, fix it before pushing. +- **Never Touch Protos or Secrets:** `core/proto/src/main/proto` is an upstream submodule — **do not modify** any `.proto` file. If a feature request requires a proto change, stop and report it as upstream (label issue `upstream`, point at `meshtastic/protobufs`). Likewise, never `git add` `app/google-services.json`, `local.properties`, `secrets.properties`, or any `*.keystore` / `*.jks` file — these are gitignored and contain secrets. +- **Multi-Flavor Install Hygiene:** When using the `android` CLI MCP to install/run on a connected device, the `fdroid` (`com.geeksville.mesh`) and `google` (`com.geeksville.mesh.google`) flavors have different signatures and **cannot coexist**. Before any install: pick a flavor explicitly, force-stop and uninstall the other flavor on every connected device, then install. Stale installs of the other flavor are a recurring source of "the fix didn't work" red herrings. +- **Verify UI With Annotated Screenshots:** For any UI/UX task, do **not** claim a fix works based on logs or assumed state. Capture an annotated screenshot via the `android` CLI MCP (or its annotated-screenshot tool) on a real connected device, and inspect the result before reporting back. +- **Branch Scope Discipline:** If a working branch grows beyond ~5 logical commits, crosses unrelated concerns, or accumulates a large blast radius, proactively propose a fresh branch off `upstream/main` and cherry-pick only the high-signal, low-risk changes (see `.skills/new-branch/SKILL.md`). Don't keep piling onto a sprawling branch. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f7d2ce900..81b8f8d86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,11 +49,18 @@ - - + + + + Пакет позиції + Інтервал трансляції GPS пристрій + Фіксована позиція Висота + Інтервал опитування GPS GPIO отримання GPS GPIO передачі GPS GPS EN GPIO @@ -264,6 +268,7 @@ Сканувати QR-код Wi-Fi Перейти назад Батарея + Завантаженість каналу Температура ґрунту Вологість ґрунту Журнали подій From f14ae2643c135a5b03b7fd050e3ad4375e66ed46 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:21:04 -0500 Subject: [PATCH 44/65] feat(node): smoother remote-admin UX with per-node session tracking (#5217) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/core/data/di/CoreDataModule.kt | 3 + .../data/manager/AdminPacketHandlerImpl.kt | 11 +- .../core/data/manager/CommandSenderImpl.kt | 13 +- .../data/manager/MeshConnectionManagerImpl.kt | 5 +- .../core/data/manager/SessionManagerImpl.kt | 118 +++++++++++++++ .../manager/AdminPacketHandlerImplTest.kt | 14 +- .../manager/MeshConnectionManagerImplTest.kt | 3 + .../data/manager/SessionManagerImplTest.kt | 117 +++++++++++++++ .../EnsureRemoteAdminSessionUseCase.kt | 113 ++++++++++++++ .../usecase/session/EnsureSessionResult.kt | 38 +++++ .../ObserveRemoteAdminSessionStatusUseCase.kt | 31 ++++ .../EnsureRemoteAdminSessionUseCaseTest.kt | 129 ++++++++++++++++ .../meshtastic/core/model/SessionStatus.kt | 42 ++++++ .../core/network/radio/MockRadioTransport.kt | 8 +- .../core/repository/CommandSender.kt | 4 - .../core/repository/SessionManager.kt | 61 ++++++++ .../composeResources/values/strings.xml | 8 + .../node/component/AdministrationSection.kt | 140 ++++++++++++++---- .../feature/node/detail/HandleNodeAction.kt | 2 + .../feature/node/detail/NodeDetailContent.kt | 11 +- .../feature/node/detail/NodeDetailScreens.kt | 1 + .../node/detail/NodeDetailViewModel.kt | 74 ++++++++- .../feature/node/model/NodeDetailAction.kt | 6 + .../node/detail/HandleNodeActionTest.kt | 12 ++ .../node/detail/NodeDetailViewModelTest.kt | 12 ++ .../feature/settings/SettingsScreen.kt | 7 +- .../settings/navigation/SettingsMainScreen.kt | 2 + .../settings/navigation/SettingsNavigation.kt | 2 + .../settings/navigation/SettingsNavigation.kt | 1 + .../settings/navigation/SettingsMainScreen.kt | 1 + 30 files changed, 931 insertions(+), 58 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionManagerImpl.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionManagerImplTest.kt create mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt create mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureSessionResult.kt create mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/ObserveRemoteAdminSessionStatusUseCase.kt create mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/SessionStatus.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SessionManager.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt index 834cff2c2..a2407008b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt @@ -21,9 +21,12 @@ import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.model.util.MeshDataMapper import org.meshtastic.core.model.util.NodeIdLookup +import kotlin.time.Clock @Module @ComponentScan("org.meshtastic.core.data") class CoreDataModule { @Single fun provideMeshDataMapper(nodeIdLookup: NodeIdLookup): MeshDataMapper = MeshDataMapper(nodeIdLookup) + + @Single fun provideClock(): Clock = Clock.System } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt index d4e0cdca2..695400cbc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt @@ -19,10 +19,10 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import org.koin.core.annotation.Single import org.meshtastic.core.repository.AdminPacketHandler -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.SessionManager import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket @@ -35,19 +35,18 @@ class AdminPacketHandlerImpl( private val nodeManager: NodeManager, private val configHandler: Lazy, private val configFlowManager: Lazy, - private val commandSender: CommandSender, + private val sessionManager: SessionManager, ) : AdminPacketHandler { override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) { val payload = packet.decoded?.payload ?: return val u = AdminMessage.ADAPTER.decode(payload) Logger.d { "Admin message from=${packet.from} fields=${u.summarize()}" } - // Guard against clearing a valid passkey: firmware always embeds the key in every - // admin response, but a missing (default-empty) field must not reset the stored value. + // Firmware embeds the session_passkey in every admin response. A missing (default-empty) + // field must not reset stored state, so only record refreshes when bytes arrived. val incomingPasskey = u.session_passkey if (incomingPasskey.size > 0) { - Logger.d { "Session passkey updated (${incomingPasskey.size} bytes)" } - commandSender.setSessionPasskey(incomingPasskey) + sessionManager.recordSession(packet.from, incomingPasskey) } val fromNum = packet.from diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index fd72ef9c7..b7e56c440 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -37,6 +37,7 @@ import org.meshtastic.core.repository.NeighborInfoHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.SessionManager import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.AirQualityMetrics @@ -60,7 +61,7 @@ import kotlin.random.Random import kotlin.time.Duration.Companion.hours import org.meshtastic.proto.Position as ProtoPosition -@Suppress("TooManyFunctions", "CyclomaticComplexMethod") +@Suppress("TooManyFunctions", "CyclomaticComplexMethod", "LongParameterList") @Single class CommandSenderImpl( private val packetHandler: PacketHandler, @@ -68,10 +69,10 @@ class CommandSenderImpl( private val radioConfigRepository: RadioConfigRepository, private val tracerouteHandler: TracerouteHandler, private val neighborInfoHandler: NeighborInfoHandler, + private val sessionManager: SessionManager, @Named("ServiceScope") private val scope: CoroutineScope, ) : CommandSender { private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue) - private val sessionPasskey = atomic(ByteString.EMPTY) private val localConfig = MutableStateFlow(LocalConfig()) private val channelSet = MutableStateFlow(ChannelSet()) @@ -93,10 +94,6 @@ class CommandSenderImpl( return ((next % numPacketIds) + 1L).toInt() } - override fun setSessionPasskey(key: ByteString) { - sessionPasskey.value = key - } - private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT /** @@ -174,7 +171,7 @@ class CommandSenderImpl( } override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) { - val adminMsg = initFn().copy(session_passkey = sessionPasskey.value) + val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum)) val packet = buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg) packetHandler.sendToRadio(packet) @@ -186,7 +183,7 @@ class CommandSenderImpl( wantResponse: Boolean, initFn: () -> AdminMessage, ): Boolean { - val adminMsg = initFn().copy(session_passkey = sessionPasskey.value) + val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum)) val packet = buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg) return packetHandler.sendToRadioAndAwait(packet) 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 022f3548d..23d07d222 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 @@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import okio.ByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch @@ -55,6 +54,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.SessionManager import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Config @@ -80,6 +80,7 @@ class MeshConnectionManagerImpl( private val historyManager: HistoryManager, private val radioConfigRepository: RadioConfigRepository, private val commandSender: CommandSender, + private val sessionManager: SessionManager, private val nodeManager: NodeManager, private val analytics: PlatformAnalytics, private val packetRepository: PacketRepository, @@ -237,7 +238,7 @@ class MeshConnectionManagerImpl( private fun tearDownConnection() { packetHandler.stopPacketQueue() - commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect. + sessionManager.clearAll() // Prevent stale per-node passkeys on reconnect. locationManager.stop() mqttManager.stop() } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionManagerImpl.kt new file mode 100644 index 000000000..497796085 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionManagerImpl.kt @@ -0,0 +1,118 @@ +/* + * 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.data.manager + +import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.update +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import okio.ByteString +import org.koin.core.annotation.Single +import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.repository.SessionManager +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * In-memory implementation of [SessionManager] backed by an atomicfu-protected [PersistentMap]. + * + * Per-node state replaces the single global passkey atomic that previously lived in `CommandSenderImpl`. Without this, + * bouncing remote-admin between two nodes within the firmware's 300 s TTL silently invalidated the first node's session + * because its passkey was overwritten by the second node's response. + * + * Threshold rationale (see `firmware/src/modules/AdminModule.cpp:1460-1481`): + * - Firmware TTL = 300 s, with passkey rotation at the 150 s halfway mark on the next response sent. + * - We treat 240 s as the "active enough to navigate without refreshing" boundary to leave headroom for in-flight + * packets, mesh latency, and clock skew. A user navigated into the remote-admin screen at 299 s would otherwise + * immediately time out on the next request. + */ +@Single +class SessionManagerImpl(private val clock: Clock) : SessionManager { + + private val entries = atomic>(persistentMapOf()) + + private val refreshFlow = MutableSharedFlow(extraBufferCapacity = REFRESH_BUFFER) + override val sessionRefreshFlow: SharedFlow = refreshFlow.asSharedFlow() + + override fun recordSession(srcNodeNum: Int, passkey: ByteString) { + if (passkey.size == 0) return + val now = clock.now() + entries.update { it.put(srcNodeNum, SessionEntry(passkey, now)) } + Logger.d { "Recorded session refresh from $srcNodeNum (${passkey.size} bytes)" } + refreshFlow.tryEmit(srcNodeNum) + } + + override fun getPasskey(destNum: Int): ByteString = entries.value[destNum]?.passkey ?: ByteString.EMPTY + + override fun clearAll() { + if (entries.value.isNotEmpty()) { + Logger.d { "Clearing ${entries.value.size} session entries" } + } + entries.value = persistentMapOf() + } + + override fun observeSessionStatus(destNum: Int): Flow = merge( + flowOf(Unit), + refreshFlow.filter { it == destNum }.map {}, + flow { + while (true) { + delay(RECHECK_INTERVAL) + emit(Unit) + } + }, + ) + .map { computeStatus(destNum) } + .distinctUntilChanged() + + private fun computeStatus(destNum: Int): SessionStatus { + val entry = entries.value[destNum] ?: return SessionStatus.NoSession + val age = clock.now() - entry.refreshedAt + return if (age < ACTIVE_THRESHOLD) { + SessionStatus.Active(entry.refreshedAt) + } else { + SessionStatus.Stale(entry.refreshedAt) + } + } + + private data class SessionEntry(val passkey: ByteString, val refreshedAt: Instant) + + companion object { + /** + * "Active enough to navigate" window. Set below the firmware TTL (300 s) to leave room for packet flight time + * and clock skew so users don't get sent into a screen that immediately times out. + */ + val ACTIVE_THRESHOLD = 240.seconds + + /** Re-emit interval for [observeSessionStatus] so the UI transitions Active → Stale without user input. */ + val RECHECK_INTERVAL = 60.seconds + + private const val REFRESH_BUFFER = 8 + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt index b416bca85..e6c2841f1 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt @@ -21,10 +21,10 @@ import dev.mokkery.mock import dev.mokkery.verify import okio.ByteString import okio.ByteString.Companion.toByteString -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.SessionManager import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -42,7 +42,7 @@ class AdminPacketHandlerImplTest { private val nodeManager = mock(MockMode.autofill) private val configHandler = mock(MockMode.autofill) private val configFlowManager = mock(MockMode.autofill) - private val commandSender = mock(MockMode.autofill) + private val sessionManager = mock(MockMode.autofill) private lateinit var handler: AdminPacketHandlerImpl @@ -55,7 +55,7 @@ class AdminPacketHandlerImplTest { nodeManager = nodeManager, configHandler = lazy { configHandler }, configFlowManager = lazy { configFlowManager }, - commandSender = commandSender, + sessionManager = sessionManager, ) } @@ -74,16 +74,16 @@ class AdminPacketHandlerImplTest { handler.handleAdminMessage(packet, myNodeNum) - verify { commandSender.setSessionPasskey(passkey) } + verify { sessionManager.recordSession(myNodeNum, passkey) } } @Test - fun `empty session passkey does not clear existing passkey`() { + fun `empty session passkey does not record refresh`() { val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY) val packet = makePacket(myNodeNum, adminMsg) handler.handleAdminMessage(packet, myNodeNum) - // setSessionPasskey should NOT be called for empty passkey + // recordSession should NOT be called for empty passkey } // ---------- get_config_response ---------- @@ -218,7 +218,7 @@ class AdminPacketHandlerImplTest { handler.handleAdminMessage(packet, myNodeNum) - verify { commandSender.setSessionPasskey(passkey) } + verify { sessionManager.recordSession(myNodeNum, passkey) } verify { configHandler.handleDeviceConfig(config) } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index 07c8914ad..fadd19542 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -50,6 +50,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.SessionManager import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.proto.Config @@ -75,6 +76,7 @@ class MeshConnectionManagerImplTest { private val historyManager = mock(MockMode.autofill) private val radioConfigRepository = mock(MockMode.autofill) private val commandSender = mock(MockMode.autofill) + private val sessionManager = mock(MockMode.autofill) private val nodeManager = mock(MockMode.autofill) private val analytics = mock(MockMode.autofill) private val packetRepository = mock(MockMode.autofill) @@ -124,6 +126,7 @@ class MeshConnectionManagerImplTest { historyManager, radioConfigRepository, commandSender, + sessionManager, nodeManager, analytics, packetRepository, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionManagerImplTest.kt new file mode 100644 index 000000000..3109f82ff --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionManagerImplTest.kt @@ -0,0 +1,117 @@ +/* + * 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.data.manager + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.ByteString +import org.meshtastic.core.model.SessionStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionManagerImplTest { + + private class MutableClock(var now: Instant = Instant.fromEpochSeconds(1_700_000_000)) : Clock { + override fun now(): Instant = now + } + + private val nodeA = 0xAAAA + private val nodeB = 0xBBBB + private val keyA = ByteString.of(1, 2, 3, 4, 5, 6, 7, 8) + private val keyB = ByteString.of(9, 8, 7, 6, 5, 4, 3, 2) + + @Test + fun `recordSession stores per-node passkeys without overwriting siblings`() { + val mgr = SessionManagerImpl(MutableClock()) + + mgr.recordSession(nodeA, keyA) + mgr.recordSession(nodeB, keyB) + + assertEquals(keyA, mgr.getPasskey(nodeA)) + assertEquals(keyB, mgr.getPasskey(nodeB)) + } + + @Test + fun `recordSession with empty passkey is a no-op`() { + val mgr = SessionManagerImpl(MutableClock()) + mgr.recordSession(nodeA, ByteString.EMPTY) + assertSame(ByteString.EMPTY, mgr.getPasskey(nodeA)) + } + + @Test + fun `clearAll wipes per-node entries`() { + val mgr = SessionManagerImpl(MutableClock()) + mgr.recordSession(nodeA, keyA) + mgr.recordSession(nodeB, keyB) + + mgr.clearAll() + + assertSame(ByteString.EMPTY, mgr.getPasskey(nodeA)) + assertSame(ByteString.EMPTY, mgr.getPasskey(nodeB)) + } + + @Test + fun `observeSessionStatus emits NoSession initially when no key recorded`() = runTest { + val mgr = SessionManagerImpl(MutableClock()) + assertEquals(SessionStatus.NoSession, mgr.observeSessionStatus(nodeA).first()) + } + + @Test + fun `observeSessionStatus reports Active for a fresh recording`() = runTest { + val clock = MutableClock() + val mgr = SessionManagerImpl(clock) + mgr.recordSession(nodeA, keyA) + + val status = mgr.observeSessionStatus(nodeA).first() + assertIs(status) + assertEquals(clock.now, status.refreshedAt) + } + + @Test + fun `observeSessionStatus reports Stale once age exceeds threshold`() = runTest { + val clock = MutableClock() + val mgr = SessionManagerImpl(clock) + mgr.recordSession(nodeA, keyA) + + // Age past the 240s active threshold; still under firmware TTL of 300s. + clock.now = clock.now.plus(250.seconds) + + val status = mgr.observeSessionStatus(nodeA).first() + assertIs(status) + } + + @Test + fun `sessionRefreshFlow emits srcNodeNum on each non-empty recording`() = runTest { + val mgr = SessionManagerImpl(MutableClock()) + + mgr.sessionRefreshFlow.test { + mgr.recordSession(nodeA, keyA) + assertEquals(nodeA, awaitItem()) + mgr.recordSession(nodeB, keyB) + assertEquals(nodeB, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt new file mode 100644 index 000000000..831f17257 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt @@ -0,0 +1,113 @@ +/* + * 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.domain.usecase.session + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.MeshActionHandler +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.SessionManager +import kotlin.time.Duration.Companion.seconds + +/** + * Ensures a remote-admin session exists for the target node, dispatching a metadata request and awaiting a refreshed + * passkey if necessary. + * + * Why this exists: the firmware embeds an 8-byte rotating passkey in every admin response and rejects admin traffic + * lacking a fresh key (`firmware/src/modules/AdminModule.cpp:1460-1481`). Before this use case the UI silently tunneled + * the user into a remote-admin screen that immediately failed if no metadata had been requested first. + * + * Concurrency model: + * - One in-flight ensure per `destNum`. Concurrent callers dedupe onto the same `Deferred` so a double-tap doesn't + * blast two metadata requests at the radio. + * - The refresh-flow subscription is established **before** the metadata request is dispatched to avoid losing the + * response on the inherently raceful `MutableSharedFlow`. + * - The `withTimeoutOrNull` is a UX deadline only — late responses still update the durable `SessionStatus` flow that + * the UI observes, so a "Timeout" outcome here can self-heal in the chip without re-tapping. + */ +@Single +open class EnsureRemoteAdminSessionUseCase( + private val sessionManager: SessionManager, + private val meshActionHandler: MeshActionHandler, + private val serviceRepository: ServiceRepository, + @Named("ServiceScope") private val serviceScope: CoroutineScope, +) { + private val mutex = Mutex() + private val inFlight = mutableMapOf>() + + @Suppress("ReturnCount") + open suspend operator fun invoke(destNum: Int): EnsureSessionResult { + if (serviceRepository.connectionState.value != ConnectionState.Connected) { + return EnsureSessionResult.Disconnected + } + if (sessionManager.observeSessionStatus(destNum).first() is SessionStatus.Active) { + return EnsureSessionResult.AlreadyActive + } + + val deferred = + mutex.withLock { + inFlight[destNum] + ?: serviceScope + .async(start = CoroutineStart.LAZY) { runEnsure(destNum) } + .also { inFlight[destNum] = it } + } + return try { + deferred.await() + } finally { + mutex.withLock { if (inFlight[destNum] === deferred) inFlight.remove(destNum) } + } + } + + private suspend fun runEnsure(destNum: Int): EnsureSessionResult { + Logger.d { "EnsureRemoteAdminSession dispatching metadata request to $destNum" } + return withTimeoutOrNull(UX_TIMEOUT) { + // Subscribe BEFORE dispatching so we don't miss the refresh emission. + val refreshed = + serviceScope.async(start = CoroutineStart.UNDISPATCHED) { + sessionManager.sessionRefreshFlow.filter { it == destNum }.first() + } + try { + meshActionHandler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) + refreshed.await() + EnsureSessionResult.Refreshed + } finally { + refreshed.cancel() + } + } ?: EnsureSessionResult.Timeout + } + + companion object { + /** + * UX deadline for surfacing a result to the user. The metadata request keeps flying after this — late responses + * still update the durable `SessionStatus` flow. + */ + val UX_TIMEOUT = 10.seconds + } +} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureSessionResult.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureSessionResult.kt new file mode 100644 index 000000000..512f597ed --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureSessionResult.kt @@ -0,0 +1,38 @@ +/* + * 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.domain.usecase.session + +/** + * Transient outcome of a single call to [EnsureRemoteAdminSessionUseCase]. This is the *event* the UI reacts to + * (snackbar / navigate / disable button) — distinct from the durable `SessionStatus` flow used by chips and gates. + */ +sealed interface EnsureSessionResult { + /** A fresh session was already on file; no admin packet was sent. */ + data object AlreadyActive : EnsureSessionResult + + /** A metadata request was dispatched and a passkey-bearing response was observed within the UX deadline. */ + data object Refreshed : EnsureSessionResult + + /** + * The metadata request was dispatched but no response arrived within the UX deadline. The request is still in + * flight and a late response will still update the durable `SessionStatus` flow. + */ + data object Timeout : EnsureSessionResult + + /** The radio is not in [org.meshtastic.core.model.ConnectionState.Connected]; no packet was sent. */ + data object Disconnected : EnsureSessionResult +} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/ObserveRemoteAdminSessionStatusUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/ObserveRemoteAdminSessionStatusUseCase.kt new file mode 100644 index 000000000..af48d0727 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/ObserveRemoteAdminSessionStatusUseCase.kt @@ -0,0 +1,31 @@ +/* + * 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.domain.usecase.session + +import kotlinx.coroutines.flow.Flow +import org.koin.core.annotation.Single +import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.repository.SessionManager + +/** + * Thin wrapper that exposes the durable per-node [SessionStatus] flow to UI consumers without leaking the + * [SessionManager] into ViewModels. + */ +@Single +open class ObserveRemoteAdminSessionStatusUseCase(private val sessionManager: SessionManager) { + open operator fun invoke(destNum: Int): Flow = sessionManager.observeSessionStatus(destNum) +} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt new file mode 100644 index 000000000..4554a08be --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt @@ -0,0 +1,129 @@ +/* + * 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.domain.usecase.session + +import dev.mokkery.MockMode +import dev.mokkery.answering.calls +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.ByteString +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.MeshActionHandler +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.SessionManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Clock + +@OptIn(ExperimentalCoroutinesApi::class) +class EnsureRemoteAdminSessionUseCaseTest { + + private val destNum = 0xCAFE + + private fun stubSessionManager( + initialStatus: SessionStatus = SessionStatus.NoSession, + refreshFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 8), + ): SessionManager { + val mgr = mock(MockMode.autofill) + every { mgr.observeSessionStatus(any()) } returns flowOf(initialStatus) + every { mgr.sessionRefreshFlow } returns refreshFlow + every { mgr.getPasskey(any()) } returns ByteString.EMPTY + return mgr + } + + private fun connectedRepo(state: ConnectionState = ConnectionState.Connected): ServiceRepository { + val repo = mock(MockMode.autofill) + every { repo.connectionState } returns MutableStateFlow(state) + return repo + } + + @Test + fun `returns Disconnected without dispatching when not connected`() = runTest { + val sessionManager = stubSessionManager() + val handler = mock(MockMode.autofill) + val useCase = + EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(ConnectionState.Disconnected), this) + + val result = useCase(destNum) + + assertEquals(EnsureSessionResult.Disconnected, result) + } + + @Test + fun `returns AlreadyActive without dispatching when status already Active`() = runTest { + val active = SessionStatus.Active(Clock.System.now()) + val sessionManager = stubSessionManager(initialStatus = active) + val handler = mock(MockMode.autofill) + val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) + + val result = useCase(destNum) + + assertEquals(EnsureSessionResult.AlreadyActive, result) + } + + @Test + fun `dispatches metadata request and returns Refreshed when refresh flow emits`() = runTest { + val refresh = MutableSharedFlow(extraBufferCapacity = 8) + val sessionManager = stubSessionManager(refreshFlow = refresh) + val handler = mock(MockMode.autofill) + // Simulate the radio responding by emitting on the refresh flow when the metadata request fires. + everySuspend { handler.onServiceAction(any()) } calls + { + refresh.tryEmit(destNum) + Unit + } + + val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) + + val result = useCase(destNum) + + assertEquals(EnsureSessionResult.Refreshed, result) + verifySuspend { handler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) } + } + + @Test + fun `returns Timeout when no refresh arrives within deadline`() = runTest { + val refresh = MutableSharedFlow(extraBufferCapacity = 8) + val sessionManager = stubSessionManager(refreshFlow = refresh) + val handler = mock(MockMode.autofill) + everySuspend { handler.onServiceAction(any()) } returns Unit + + val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) + + var observed: EnsureSessionResult? = null + val job = launch { observed = useCase(destNum) } + advanceTimeBy(EnsureRemoteAdminSessionUseCase.UX_TIMEOUT.inWholeMilliseconds + 100) + advanceUntilIdle() + job.join() + + assertEquals(EnsureSessionResult.Timeout, observed) + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SessionStatus.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SessionStatus.kt new file mode 100644 index 000000000..90a66c0ee --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/SessionStatus.kt @@ -0,0 +1,42 @@ +/* + * 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 + +import kotlin.time.Instant + +/** + * Durable per-node remote-administration session status, derived from the time of the last admin response that carried + * a `session_passkey` from the target node. + * + * The Meshtastic firmware enforces a 300 s session TTL and rotates the passkey at the 150 s mark when sending any admin + * response (see `firmware/src/modules/AdminModule.cpp:1460-1481`). To leave headroom for in-flight packets and clock + * skew, the Android client treats sessions older than 240 s as [Stale] — still potentially usable for a single ping but + * the UI should refresh before navigating the user into a screen that fires more admin requests. + */ +sealed interface SessionStatus { + /** No admin response with a session passkey has ever been observed for this node since connect. */ + data object NoSession : SessionStatus + + /** A fresh session passkey is on file and is well within the firmware TTL. */ + data class Active(val refreshedAt: Instant) : SessionStatus + + /** + * A session passkey is on file but the firmware may have already rotated it or be about to expire it; refresh + * before sending further admin traffic. + */ + data class Stale(val refreshedAt: Instant) : SessionStatus +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt index f8edeaa73..0443bb0d4 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt @@ -66,6 +66,10 @@ class MockRadioTransport( companion object { private const val MY_NODE = 0x42424242 + + @Suppress("MagicNumber") + private val FAKE_SESSION_PASSKEY: okio.ByteString = + okio.ByteString.of(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77) } private var currentPacketId = 50 @@ -297,7 +301,9 @@ class MockRadioTransport( ) private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminMessage.() -> AdminMessage) { - val adminMsg = AdminMessage().initFn() + // Embed a deterministic 8-byte fake passkey so SessionManager can record a session refresh — mirrors what real + // firmware always attaches to admin responses (see firmware/src/modules/AdminModule.cpp:1460-1481). + val adminMsg = AdminMessage().initFn().copy(session_passkey = FAKE_SESSION_PASSKEY) val p = makeDataPacket( fromIn, diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index b99a002de..c5c5bde07 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.repository -import okio.ByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Position import org.meshtastic.proto.AdminMessage @@ -38,9 +37,6 @@ interface CommandSender { /** Generates a new unique packet ID. */ fun generatePacketId(): Int - /** Sets the session passkey for admin messages. */ - fun setSessionPasskey(key: ByteString) - /** Sends a data packet to the mesh. */ fun sendData(p: DataPacket) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SessionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SessionManager.kt new file mode 100644 index 000000000..253a833b8 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SessionManager.kt @@ -0,0 +1,61 @@ +/* + * 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.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import okio.ByteString +import org.meshtastic.core.model.SessionStatus + +/** + * Owns per-node remote-administration session state — the session passkey the firmware embeds in every admin response + * and the timestamp it was last refreshed at. + * + * Replaces the single global passkey atomic that previously lived in `CommandSenderImpl`, which silently invalidated + * the session of node A as soon as node B responded with a different key (the multi-remote-admin bug). + * + * Lifecycle: + * - [recordSession] is called by the admin packet handler whenever an inbound admin response carries a non-empty + * `session_passkey`. + * - [getPasskey] is read on the send path to attach the appropriate per-destination key. + * - [clearAll] is called on radio teardown to prevent stale keys from surviving a reconnect. + */ +interface SessionManager { + /** Record an inbound session refresh from [srcNodeNum]. No-op for empty [passkey]. */ + fun recordSession(srcNodeNum: Int, passkey: ByteString) + + /** Returns the most recently observed passkey for [destNum], or [ByteString.EMPTY] if none. */ + fun getPasskey(destNum: Int): ByteString + + /** Clears all per-node session state. Call on radio disconnect / teardown. */ + fun clearAll() + + /** + * Hot stream of `srcNodeNum` values, emitted exactly once per call to [recordSession] with a non-empty passkey. + * Used by `EnsureRemoteAdminSessionUseCase` to await a session refresh from a specific node without polling. + * + * Backed by a `MutableSharedFlow` with no replay; subscribers must subscribe **before** dispatching the request + * that triggers the refresh. + */ + val sessionRefreshFlow: SharedFlow + + /** + * Cold per-node [SessionStatus] flow. Emits the current status synchronously on subscription and re-emits whenever + * the underlying state crosses the staleness threshold. + */ + fun observeSessionStatus(destNum: Int): Flow +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 505d80821..2ef01ef24 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -816,6 +816,14 @@ Host Metrics Pax Metrics Metadata + Refresh metadata + Connect & administer + Establishing remote session… + Session active + Refresh required + Connect to a radio to administer remote nodes. + Could not reach node — try again or move closer. + Retry Actions Firmware Use 12h clock format diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index 23ef010e8..235118876 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -16,27 +16,39 @@ */ package org.meshtastic.feature.node.component +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.Node -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.navigation.SettingsRoute +import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.administration +import org.meshtastic.core.resources.connect_radio_for_remote_admin +import org.meshtastic.core.resources.establishing_session import org.meshtastic.core.resources.firmware import org.meshtastic.core.resources.firmware_edition import org.meshtastic.core.resources.installed_firmware_version import org.meshtastic.core.resources.latest_alpha_firmware import org.meshtastic.core.resources.latest_stable_firmware +import org.meshtastic.core.resources.refresh_metadata import org.meshtastic.core.resources.remote_admin -import org.meshtastic.core.resources.request_metadata +import org.meshtastic.core.resources.session_active +import org.meshtastic.core.resources.session_refresh_required +import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.icon.ForkLeft import org.meshtastic.core.ui.icon.Icecream @@ -57,35 +69,113 @@ fun AdministrationSection( metricsState: MetricsState, onAction: (NodeDetailAction) -> Unit, onFirmwareSelect: (FirmwareRelease) -> Unit, + sessionStatus: SessionStatus, + isEnsuringSession: Boolean, modifier: Modifier = Modifier, ) { - SectionCard(title = Res.string.administration, modifier = modifier) { - Column { - ListItem( - text = stringResource(Res.string.request_metadata), - leadingIcon = MeshtasticIcons.Memory, - trailingIcon = null, - onClick = { - onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num))) - }, - ) + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(24.dp)) { + SectionCard(title = Res.string.administration) { + Column { + // Local nodes don't need a session — they short-circuit straight to the settings screen. + if (metricsState.isLocal) { + ListItem( + text = stringResource(Res.string.remote_admin), + leadingIcon = MeshtasticIcons.Settings, + onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(node.num)) }, + ) + } else { + RemoteAdminListItem( + nodeNum = node.num, + sessionStatus = sessionStatus, + isEnsuringSession = isEnsuringSession, + onAction = onAction, + ) - SectionDivider() + SectionDivider() - ListItem( - text = stringResource(Res.string.remote_admin), - leadingIcon = MeshtasticIcons.Settings, - enabled = metricsState.isLocal || node.metadata != null, - ) { - onAction(NodeDetailAction.Navigate(SettingsRoute.Settings(node.num))) + ListItem( + text = stringResource(Res.string.refresh_metadata), + leadingIcon = MeshtasticIcons.Memory, + trailingIcon = null, + enabled = !isEnsuringSession, + onClick = { onAction(NodeDetailAction.RefreshMetadata(node.num)) }, + ) + } } } - } - val firmwareVersion = node.metadata?.firmware_version - val firmwareEdition = metricsState.firmwareEdition - if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) { - FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect) + val firmwareVersion = node.metadata?.firmware_version + val firmwareEdition = metricsState.firmwareEdition + if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) { + FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect) + } + } +} + +/** + * Single primary affordance for opening the remote-admin screen. Replaces the prior two-row, no-feedback flow that + * required the user to know they had to tap "Metadata" first to populate `node.metadata` before "Remote Administration" + * un-greyed out. The session passkey freshness — not the metadata insert — is the real gate (see + * `firmware/src/modules/AdminModule.cpp:1460-1481`), and is now reflected via an [AssistChip] + inline progress. + */ +@Composable +private fun RemoteAdminListItem( + nodeNum: Int, + sessionStatus: SessionStatus, + isEnsuringSession: Boolean, + onAction: (NodeDetailAction) -> Unit, +) { + val supportingTextRes = + when (sessionStatus) { + SessionStatus.NoSession -> Res.string.connect_radio_for_remote_admin + is SessionStatus.Active -> null + is SessionStatus.Stale -> Res.string.session_refresh_required + } + val chipLabelRes = + when (sessionStatus) { + SessionStatus.NoSession -> null + is SessionStatus.Active -> Res.string.session_active + is SessionStatus.Stale -> Res.string.session_refresh_required + } + + Column { + BasicListItem( + text = stringResource(Res.string.remote_admin), + leadingIcon = MeshtasticIcons.Settings, + supportingText = supportingTextRes?.let { stringResource(it) }, + enabled = !isEnsuringSession, + trailingContent = + chipLabelRes?.let { res -> + { + AssistChip( + onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) }, + label = { androidx.compose.material3.Text(stringResource(res)) }, + enabled = !isEnsuringSession, + colors = + if (sessionStatus is SessionStatus.Active) { + AssistChipDefaults.assistChipColors( + labelColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) + } else { + AssistChipDefaults.assistChipColors() + }, + ) + } + }, + onClick = { onAction(NodeDetailAction.OpenRemoteAdmin(nodeNum)) }, + ) + AnimatedVisibility(visible = isEnsuringSession) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + androidx.compose.material3.Text( + text = stringResource(Res.string.establishing_session), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } } } 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 559582417..9efc50d5b 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 @@ -37,6 +37,8 @@ internal fun handleNodeAction( when (action) { is NodeDetailAction.Navigate -> onNavigate(action.route) is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action) + is NodeDetailAction.OpenRemoteAdmin -> viewModel.openRemoteAdmin(action.nodeNum) + is NodeDetailAction.RefreshMetadata -> viewModel.refreshMetadata(action.nodeNum) is NodeDetailAction.HandleNodeMenuAction -> { when (val menuAction = action.action) { is NodeMenuAction.DirectMessage -> { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index 03367debf..f19ebc37d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -119,7 +119,16 @@ fun NodeDetailList( } item { NotesSection(node = node, onSaveNotes = onSaveNotes) } if (!uiState.metricsState.isManaged) { - item { AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect) } + item { + AdministrationSection( + node = node, + metricsState = uiState.metricsState, + onAction = onAction, + onFirmwareSelect = onFirmwareSelect, + sessionStatus = uiState.sessionStatus, + isEnsuringSession = uiState.isEnsuringSession, + ) + } } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index 951648e29..1d2d3022b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -67,6 +67,7 @@ fun NodeDetailScreen( ) { LaunchedEffect(nodeId) { viewModel.start(nodeId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(viewModel) { viewModel.navigationEvents.collect { onNavigate(it) } } NodeDetailScaffold( modifier = modifier, uiState = uiState, 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 e891d8ae0..e2d552c9b 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 @@ -20,19 +20,32 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase +import org.meshtastic.core.domain.usecase.session.EnsureSessionResult +import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node +import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.navigation.Route +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText +import org.meshtastic.core.resources.connect_radio_for_remote_admin +import org.meshtastic.core.resources.remote_admin_unreachable +import org.meshtastic.core.ui.util.SnackbarManager import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase @@ -51,6 +64,8 @@ data class NodeDetailUiState( val availableLogs: Set = emptySet(), val lastTracerouteTime: Long? = null, val lastRequestNeighborsTime: Long? = null, + val sessionStatus: SessionStatus = SessionStatus.NoSession, + val isEnsuringSession: Boolean = false, ) /** @@ -58,12 +73,16 @@ data class NodeDetailUiState( */ @OptIn(ExperimentalCoroutinesApi::class) @KoinViewModel +@Suppress("LongParameterList") class NodeDetailViewModel( private val savedStateHandle: SavedStateHandle, private val nodeManagementActions: NodeManagementActions, private val nodeRequestActions: NodeRequestActions, private val serviceRepository: ServiceRepository, private val getNodeDetailsUseCase: GetNodeDetailsUseCase, + private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase, + private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase, + private val snackbarManager: SnackbarManager, ) : ViewModel() { private val nodeIdFromRoute: Int? = savedStateHandle.get("destNum") @@ -73,12 +92,32 @@ class NodeDetailViewModel( combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute } .distinctUntilChanged() + private val isEnsuringSession = MutableStateFlow(false) + + private val sessionStatusFlow = + activeNodeId.flatMapLatest { nodeId -> + if (nodeId == null) flowOf(SessionStatus.NoSession) else observeRemoteAdminSessionStatus(nodeId) + } + + /** One-shot navigation events from session-bearing actions (e.g. successful remote-admin opens). */ + private val _navigationEvents = Channel(capacity = Channel.BUFFERED) + val navigationEvents: Flow = _navigationEvents.receiveAsFlow() + /** Primary UI state stream, combining identity, metrics, and global device metadata. */ val uiState: StateFlow = activeNodeId .flatMapLatest { nodeId -> - if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState()) - getNodeDetailsUseCase(nodeId) + if (nodeId == null) { + flowOf(NodeDetailUiState()) + } else { + combine(getNodeDetailsUseCase(nodeId), sessionStatusFlow, isEnsuringSession) { + base, + sessionStatus, + ensuring, + -> + base.copy(sessionStatus = sessionStatus, isEnsuringSession = ensuring) + } + } } .stateInWhileSubscribed(initialValue = NodeDetailUiState()) @@ -117,6 +156,37 @@ class NodeDetailViewModel( fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) } + /** + * Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a + * snackbar with the appropriate guidance on [EnsureSessionResult.Disconnected] or [EnsureSessionResult.Timeout]. + */ + fun openRemoteAdmin(destNum: Int) { + if (isEnsuringSession.value) return + viewModelScope.launch { + isEnsuringSession.value = true + try { + when (ensureRemoteAdminSession(destNum)) { + EnsureSessionResult.AlreadyActive, + EnsureSessionResult.Refreshed, + -> _navigationEvents.trySend(SettingsRoute.Settings(destNum)) + EnsureSessionResult.Disconnected -> + snackbarManager.showSnackbar( + UiText.Resource(Res.string.connect_radio_for_remote_admin).resolve(), + ) + EnsureSessionResult.Timeout -> + snackbarManager.showSnackbar(UiText.Resource(Res.string.remote_admin_unreachable).resolve()) + } + } finally { + isEnsuringSession.value = false + } + } + } + + /** + * Re-fetch device metadata (firmware/edition/role) for [destNum]. Refreshes the session passkey as a side effect. + */ + fun refreshMetadata(destNum: Int) = onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) + fun setNodeNotes(nodeNum: Int, notes: String) { nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt index 1f93a15ba..ee4116fc8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt @@ -29,6 +29,12 @@ sealed interface NodeDetailAction { data class HandleNodeMenuAction(val action: NodeMenuAction) : NodeDetailAction + /** Open the remote-administration screen, ensuring a fresh session passkey first. */ + data class OpenRemoteAdmin(val nodeNum: Int) : NodeDetailAction + + /** Force-refresh device metadata (firmware version, edition, role) for the given node. */ + data class RefreshMetadata(val nodeNum: Int) : NodeDetailAction + data object ShareContact : NodeDetailAction // Opens the compass sheet scoped to a target node and the user’s preferred units. 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 index 6bca8822b..c7504dfe4 100644 --- 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 @@ -25,12 +25,17 @@ import dev.mokkery.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase +import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node +import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.util.SnackbarManager import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.model.NodeDetailAction @@ -48,11 +53,15 @@ class HandleNodeActionTest { private val nodeRequestActions: NodeRequestActions = mock() private val serviceRepository: ServiceRepository = mock() private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() + private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock() + private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock() + private val snackbarManager: SnackbarManager = mock() @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) every { getNodeDetailsUseCase(any()) } returns emptyFlow() + every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession) } @AfterTest @@ -86,5 +95,8 @@ class HandleNodeActionTest { nodeRequestActions = nodeRequestActions, serviceRepository = serviceRepository, getNodeDetailsUseCase = getNodeDetailsUseCase, + ensureRemoteAdminSession = ensureRemoteAdminSession, + observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus, + snackbarManager = snackbarManager, ) } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt index c3ed67b5b..fc9ed6b2a 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt @@ -27,12 +27,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase +import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node +import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.util.SnackbarManager import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.proto.User @@ -51,12 +56,16 @@ class NodeDetailViewModelTest { private val nodeRequestActions: NodeRequestActions = mock() private val serviceRepository: ServiceRepository = mock() private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() + private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock() + private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock() + private val snackbarManager: SnackbarManager = mock() @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) every { getNodeDetailsUseCase(any()) } returns emptyFlow() + every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession) viewModel = createViewModel(1234) } @@ -67,6 +76,9 @@ class NodeDetailViewModelTest { nodeRequestActions = nodeRequestActions, serviceRepository = serviceRepository, getNodeDetailsUseCase = getNodeDetailsUseCase, + ensureRemoteAdminSession = ensureRemoteAdminSession, + observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus, + snackbarManager = snackbarManager, ) @AfterTest diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index f30a12d52..53e2b7323 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -80,6 +80,7 @@ fun SettingsScreen( viewModel: RadioConfigViewModel, onClickNodeChip: (Int) -> Unit = {}, onNavigate: (Route) -> Unit = {}, + onBack: (() -> Unit)? = null, ) { val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle() val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle() @@ -167,6 +168,8 @@ fun SettingsScreen( Scaffold( topBar = { + // Show back arrow when remotely administering (caller supplies onBack and we're not on the local node). + val showBack = onBack != null && !state.isLocal MainAppBar( title = stringResource(Res.string.bottom_nav_settings), subtitle = @@ -178,8 +181,8 @@ fun SettingsScreen( }, ourNode = ourNode, showNodeChip = ourNode != null && isConnected && state.isLocal, - canNavigateUp = false, - onNavigateUp = {}, + canNavigateUp = showBack, + onNavigateUp = { onBack?.invoke() }, actions = {}, onClickChip = { node -> onClickNodeChip(node.num) }, ) diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt index 773664c1f..295dcb8c5 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt @@ -28,11 +28,13 @@ actual fun SettingsMainScreen( radioConfigViewModel: RadioConfigViewModel, onClickNodeChip: (Int) -> Unit, onNavigate: (Route) -> Unit, + onBack: (() -> Unit)?, ) { SettingsScreen( settingsViewModel = settingsViewModel, viewModel = radioConfigViewModel, onClickNodeChip = onClickNodeChip, onNavigate = onNavigate, + onBack = onBack, ) } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 1ee791620..3eba4663b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -100,6 +100,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { radioConfigViewModel = getRadioConfigViewModel(backStack), onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, onNavigate = { backStack.add(it) }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -233,6 +234,7 @@ expect fun SettingsMainScreen( radioConfigViewModel: RadioConfigViewModel, onClickNodeChip: (Int) -> Unit, onNavigate: (Route) -> Unit, + onBack: (() -> Unit)? = null, ) /** Expect declarations for platform-specific config screens. */ diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 75f37c06e..37601415e 100644 --- a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -27,6 +27,7 @@ actual fun SettingsMainScreen( radioConfigViewModel: RadioConfigViewModel, onClickNodeChip: (Int) -> Unit, onNavigate: (Route) -> Unit, + onBack: (() -> Unit)?, ) { // TODO: Implement iOS settings main screen } diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt index cd7095eae..617cd25b7 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt @@ -28,6 +28,7 @@ actual fun SettingsMainScreen( radioConfigViewModel: RadioConfigViewModel, onClickNodeChip: (Int) -> Unit, onNavigate: (Route) -> Unit, + onBack: (() -> Unit)?, ) { DesktopSettingsScreen( settingsViewModel = settingsViewModel, From 228d872f9d460173d794b37f49cf4ac042d9826e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:34:22 -0500 Subject: [PATCH 45/65] feat(connections): unified device list, ACCESS_LOCAL_NETWORK, transport filter chips (#5219) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 12 + .../kotlin/org/meshtastic/app/MainActivity.kt | 8 +- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 17 +- .../org/meshtastic/core/ble/BleDevice.kt | 9 + .../core/ble/MeshtasticBleDevice.kt | 2 + .../core/database/DatabaseManager.kt | 35 +- .../meshtastic/core/model/ChannelOption.kt | 4 + .../repository/UsbBroadcastReceiver.kt | 4 - .../core/network/repository/UsbRepository.kt | 6 +- .../network/repository/MQTTRepositoryImpl.kt | 6 +- .../repository/MQTTRepositoryImplTest.kt | 24 +- .../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 40 ++ .../core/repository/AppPreferences.kt | 25 + .../composeResources/values/strings.xml | 24 + .../core/testing/FakeAppPreferences.kt | 30 ++ .../core/ui/util/ModelExtensions.kt | 8 + .../core/ui/viewmodel/ConnectionsViewModel.kt | 40 ++ .../kotlin/org/meshtastic/desktop/Main.kt | 10 +- .../desktop/navigation/DesktopNavigation.kt | 9 +- .../desktop/ui/DesktopMainScreen.kt | 2 +- .../connections/AndroidScannerViewModel.kt | 6 + .../AndroidGetDiscoveredDevicesUseCase.kt | 75 ++- .../feature/connections/Constants.kt | 33 ++ .../feature/connections/ScannerViewModel.kt | 363 ++++++++----- .../CommonGetDiscoveredDevicesUseCase.kt | 62 ++- .../connections/model/DiscoveredDevices.kt | 10 +- .../connections/ui/ConnectionsScreen.kt | 397 +++++++------- .../connections/ui/components/BLEDevices.kt | 89 ---- .../ui/components/ConnectingDeviceInfo.kt | 47 +- .../ui/components/ConnectionActionButton.kt | 97 ++++ .../components/ConnectionActionButtonStyle.kt | 25 + .../ui/components/ConnectionsSegmentedBar.kt | 69 --- .../ui/components/CurrentlyConnectedInfo.kt | 58 +-- .../connections/ui/components/DeviceList.kt | 486 ++++++++++++++++++ .../ui/components/DeviceListItem.kt | 42 +- .../ui/components/DeviceListSection.kt | 70 --- .../ui/components/DeviceSectionHeader.kt | 66 +++ .../ui/components/DisconnectButton.kt | 44 ++ .../ui/components/EmptyStateContent.kt | 4 +- .../ui/components/NetworkDevices.kt | 200 ------- .../ui/components/TransportFilterChips.kt | 89 ++++ .../connections/ui/components/UsbDevices.kt | 54 -- .../connections/ScannerViewModelTest.kt | 86 +++- .../CommonGetDiscoveredDevicesUseCaseTest.kt | 51 +- .../connections/JvmScannerViewModel.kt | 6 + .../usecase/JvmGetDiscoveredDevicesUseCase.kt | 42 ++ .../feature/node/list/NodeListScreen.kt | 85 +++ .../feature/node/list/NodeListViewModel.kt | 6 +- .../node/navigation/AdaptiveNodeListScreen.kt | 2 + .../node/navigation/NodesNavigation.kt | 7 +- 50 files changed, 1972 insertions(+), 1014 deletions(-) create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/Constants.kt delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionActionButton.kt create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionActionButtonStyle.kt delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceList.kt delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListSection.kt create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceSectionHeader.kt create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DisconnectButton.kt delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt create mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/TransportFilterChips.kt delete mode 100644 feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt create mode 100644 feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmGetDiscoveredDevicesUseCase.kt diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e856cbe8f..fa1a240d1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,3 +4,15 @@ You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks. + +## Critical reminders (do not skip) + +These rules live in `AGENTS.md` but are inlined here because past sessions repeatedly violated them: + +- **Never modify `core/proto/src/main/proto/*.proto`** — it's an upstream submodule. If a feature needs a proto change, stop and label the issue `upstream` pointing at `meshtastic/protobufs`. +- **Verify-then-push, never "should be green".** Before any `git push`, run `./gradlew spotlessApply detekt` plus relevant `:module:test` for touched modules. After pushing, do **not** claim CI is green based on assumption — fetch the actual status with `gh pr checks ` (or `gh run list --branch --limit 5`) and only report success once the checks return ✅. Phrases like "CI should be green now" are banned. + +## Tooling conventions + +- **Engage the `android-cli` skill automatically.** Whenever the user mentions adb, emulator, device install/run, screenshots, or named devices (e.g. `1c10`, `Pixel 6a`), invoke the `android-cli` skill *before* running raw `adb` or `gradle install*` commands. Don't wait for the user to paste the skill-context block. +- **Screenshots and ad-hoc artifacts go to `/tmp/`.** Never write annotated screenshots, log dumps, or scratch files into the repo working tree — they pollute `git status` and risk accidental commits. Use `/tmp/` or the session workspace (`~/.copilot/session-state//files/`). diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 628865010..5a2a2e2c9 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -275,7 +275,7 @@ class MainActivity : ComponentActivity() { // 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() + showConnectionsPage() } Intent.ACTION_MAIN -> {} @@ -314,7 +314,7 @@ class MainActivity : ComponentActivity() { return resultPendingIntent!! } - private fun createSettingsIntent(): PendingIntent { + private fun createConnectionsIntent(): PendingIntent { val deepLink = "$DEEP_LINK_BASE_URI/connections" val startActivityIntent = Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply { @@ -329,7 +329,7 @@ class MainActivity : ComponentActivity() { return resultPendingIntent!! } - private fun showSettingsPage() { - createSettingsIntent().send() + private fun showConnectionsPage() { + createConnectionsIntent().send() } } diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 1e5b68ab0..37990f260 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -33,6 +33,7 @@ import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.app.BuildConfig import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.navigation.NodesRoute +import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_too_old @@ -53,7 +54,15 @@ import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph @Composable fun MainScreen() { val viewModel: UIViewModel = koinViewModel() - val multiBackstack = rememberMultiBackstack(NodesRoute.NodesGraph) + // Land on Connections for first-run / no-device-selected; otherwise on Nodes. Read synchronously + // from the StateFlow (seeded from persisted prefs) so the initial tab is set in one shot. + val initialTab = + if (viewModel.currentDeviceAddressFlow.value.isNullOrSelectedNone()) { + TopLevelDestination.Connections.route + } else { + NodesRoute.NodesGraph + } + val multiBackstack = rememberMultiBackstack(initialTab) val backStack = multiBackstack.activeBackStack AndroidAppVersionCheck(viewModel) @@ -71,6 +80,9 @@ fun MainScreen() { backStack = backStack, scrollToTopEvents = viewModel.scrollToTopEventFlow, onHandleDeepLink = viewModel::handleDeepLink, + onNavigateToConnections = { + multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) + }, ) mapGraph(backStack) channelsGraph(backStack) @@ -88,6 +100,9 @@ fun MainScreen() { } } +/** True when no device address is persisted, or the address is the "none" sentinel (`"n"`). */ +private fun String?.isNullOrSelectedNone(): Boolean = isNullOrBlank() || this == "n" + @Composable @Suppress("LongMethod", "CyclomaticComplexMethod") private fun AndroidAppVersionCheck(viewModel: UIViewModel) { diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleDevice.kt index 8c3278b26..5231080ea 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleDevice.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleDevice.kt @@ -35,6 +35,15 @@ interface BleDevice { /** Whether the device is currently connected. */ val isConnected: Boolean + /** + * The RSSI reported by the most recent scan advertisement for this device, in dBm. + * + * `null` for devices that have not been observed via a scan (e.g. bonded-only devices retrieved from the OS). This + * is a snapshot — to see live updates, observe a flow of [BleDevice] instances from [BleScanner]. + */ + val rssi: Int? + get() = null + /** Reads the current RSSI value. */ suspend fun readRssi(): Int diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt index 3342cf24f..0e2d7e530 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt @@ -48,6 +48,8 @@ class MeshtasticBleDevice( override val isConnected: Boolean get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address + override val rssi: Int? = advertisement?.rssi + @OptIn(ExperimentalApi::class) override suspend fun readRssi(): Int { val active = ActiveBleConnection.active diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt index 108345265..70fbf8b31 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -192,12 +192,27 @@ open class DatabaseManager( } } - /** Returns true if a database exists for the given device address. */ + /** + * Returns true if a database exists for the given device address. Android Room stores DB files without an + * extension; JVM/iOS append `.db`. We check both to stay platform-agnostic. + */ override fun hasDatabaseFor(address: String?): Boolean { if (address.isNullOrBlank() || address == "n") return false val dbName = buildDbName(address) - val path = getDatabaseDirectory().resolve("$dbName.db") - return getFileSystem().exists(path) + return dbFileExists(dbName) + } + + private fun dbFileExists(dbName: String): Boolean { + val dir = getDatabaseDirectory() + val fs = getFileSystem() + return fs.exists(dir.resolve(dbName)) || fs.exists(dir.resolve("$dbName.db")) + } + + private fun dbFileMetadataMillis(dbName: String): Long? { + val dir = getDatabaseDirectory() + val fs = getFileSystem() + return fs.metadataOrNull(dir.resolve(dbName))?.lastModifiedAtMillis + ?: fs.metadataOrNull(dir.resolve("$dbName.db"))?.lastModifiedAtMillis } private fun markLastUsed(dbName: String) { @@ -208,8 +223,7 @@ open class DatabaseManager( val key = lastUsedKey(dbName) val v = datastore.data.first()[key] ?: 0L return if (v == 0L) { - val path = getDatabaseDirectory().resolve("$dbName.db") - getFileSystem().metadataOrNull(path)?.lastModifiedAtMillis ?: 0L + dbFileMetadataMillis(dbName) ?: 0L } else { v } @@ -221,11 +235,14 @@ open class DatabaseManager( if (!fs.exists(dir)) return emptyList() return fs.list(dir) + .asSequence() .map { it.name } .filter { it.startsWith(DatabaseConstants.DB_PREFIX) } - .filter { it.endsWith(".db") } + // Skip Room-internal sidecar files (-wal/-shm/-journal) and lock files so each DB appears exactly once. + .filterNot { it.endsWith("-wal") || it.endsWith("-shm") || it.endsWith("-journal") || it.endsWith(".lck") } .map { it.removeSuffix(".db") } .distinct() + .toList() } private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock { @@ -261,11 +278,7 @@ open class DatabaseManager( return@withLock } - val dir = getDatabaseDirectory() - val fs = getFileSystem() - val legacyPath = dir.resolve("$legacy.db") - - if (fs.exists(legacyPath)) { + if (dbFileExists(legacy)) { runCatching { // runCatching intentional: best-effort cleanup must not abort on cancellation closeCachedDatabase(legacy) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt index c455bad21..567dd1900 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt @@ -307,6 +307,10 @@ enum class ChannelOption(val modemPreset: ModemPreset, val bandwidth: Float) { SHORT_FAST(ModemPreset.SHORT_FAST, 0.250f), SHORT_SLOW(ModemPreset.SHORT_SLOW, 0.250f), SHORT_TURBO(ModemPreset.SHORT_TURBO, 0.500f), + LITE_FAST(ModemPreset.LITE_FAST, 0.125f), + LITE_SLOW(ModemPreset.LITE_SLOW, 0.125f), + NARROW_FAST(ModemPreset.NARROW_FAST, 0.0625f), + NARROW_SLOW(ModemPreset.NARROW_SLOW, 0.0625f), ; companion object { diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbBroadcastReceiver.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbBroadcastReceiver.kt index 79d09639a..faa2fa898 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbBroadcastReceiver.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbBroadcastReceiver.kt @@ -51,10 +51,6 @@ class UsbBroadcastReceiver(private val usbRepository: UsbRepository) : Broadcast Logger.d { "USB device '$deviceName' was attached" } usbRepository.refreshState() } - UsbManager.EXTRA_PERMISSION_GRANTED -> { - Logger.d { "USB device '$deviceName' was granted permission" } - usbRepository.refreshState() - } } } } 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 c5080ec14..592cb362a 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 @@ -60,10 +60,14 @@ class UsbRepository( init { processLifecycle.coroutineScope.launch(dispatchers.default) { - refreshStateInternal() + // Register the attach/detach receiver first so that events fired while we are + // scanning the current device list are not dropped. The receiver resolution must + // happen off the construction thread to avoid a Koin cycle + // (UsbRepository <-> UsbBroadcastReceiver). usbBroadcastReceiverLazy.value.let { receiver -> application.registerReceiverCompat(receiver, receiver.intentFilter) } + refreshStateInternal() } } 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 537e2af37..51e05f9df 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 @@ -238,7 +238,9 @@ private const val MQTT_PORT_TLS = 8883 fun resolveEndpoint(rawAddress: String, tlsEnabled: Boolean): MqttEndpoint = if (rawAddress.contains("://")) { MqttEndpoint.parse(rawAddress) } else { - val port = if (tlsEnabled) MQTT_PORT_TLS else MQTT_PORT_PLAIN val scheme = if (tlsEnabled) "ssl" else "tcp" - MqttEndpoint.parse("$scheme://$rawAddress:$port") + val defaultPort = if (tlsEnabled) MQTT_PORT_TLS else MQTT_PORT_PLAIN + // Preserve the user-supplied port (if any) instead of naively appending the default. + val hostAndPort = if (rawAddress.contains(":")) rawAddress else "$rawAddress:$defaultPort" + MqttEndpoint.parse("$scheme://$hostAndPort") } 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 26b83a420..96079f039 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 @@ -29,27 +29,33 @@ class MQTTRepositoryImplTest { // region resolveEndpoint — every behavioral branch of address parsing. @Test - fun `bare host without scheme is wrapped as ws WebSocket on the standard port`() { + fun `bare host without scheme is wrapped as plain Tcp on the standard MQTT port`() { val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = false) - val ws = assertIs(endpoint) - assertEquals("ws://broker.example.com/mqtt", ws.url) + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(1883, tcp.port) + assertEquals(false, tcp.tls) } @Test - fun `bare host with TLS enabled is upgraded to wss`() { + fun `bare host with TLS enabled is wrapped as Tcp on the secure MQTT port`() { val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = true) - val ws = assertIs(endpoint) - assertEquals("wss://broker.example.com/mqtt", ws.url) + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(8883, tcp.port) + assertEquals(true, tcp.tls) } @Test - fun `host with explicit port is preserved when wrapped`() { + fun `host with explicit port is preserved when wrapped as Tcp`() { val endpoint = resolveEndpoint(rawAddress = "broker.example.com:9001", tlsEnabled = false) - val ws = assertIs(endpoint) - assertEquals("ws://broker.example.com:9001/mqtt", ws.url) + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(9001, tcp.port) + assertEquals(false, tcp.tls) } @Test diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index c0b88d385..e94b51dd0 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -141,6 +141,41 @@ class UiPrefsImpl( scope.launch { dataStore.edit { it[KEY_SHOW_QUICK_CHAT_PREF] = show } } } + override val bleAutoScan: StateFlow = + dataStore.data.map { it[KEY_BLE_AUTO_SCAN] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) + + override fun setBleAutoScan(enabled: Boolean) { + scope.launch { dataStore.edit { it[KEY_BLE_AUTO_SCAN] = enabled } } + } + + override val networkAutoScan: StateFlow = + dataStore.data.map { it[KEY_NETWORK_AUTO_SCAN] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) + + override fun setNetworkAutoScan(enabled: Boolean) { + scope.launch { dataStore.edit { it[KEY_NETWORK_AUTO_SCAN] = enabled } } + } + + override val showBleTransport: StateFlow = + dataStore.data.map { it[KEY_SHOW_BLE_TRANSPORT] ?: true }.stateIn(scope, SharingStarted.Eagerly, true) + + override fun setShowBleTransport(enabled: Boolean) { + scope.launch { dataStore.edit { it[KEY_SHOW_BLE_TRANSPORT] = enabled } } + } + + override val showNetworkTransport: StateFlow = + dataStore.data.map { it[KEY_SHOW_NETWORK_TRANSPORT] ?: true }.stateIn(scope, SharingStarted.Eagerly, true) + + override fun setShowNetworkTransport(enabled: Boolean) { + scope.launch { dataStore.edit { it[KEY_SHOW_NETWORK_TRANSPORT] = enabled } } + } + + override val showUsbTransport: StateFlow = + dataStore.data.map { it[KEY_SHOW_USB_TRANSPORT] ?: true }.stateIn(scope, SharingStarted.Eagerly, true) + + override fun setShowUsbTransport(enabled: Boolean) { + scope.launch { dataStore.edit { it[KEY_SHOW_USB_TRANSPORT] = enabled } } + } + override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow = cachedFlow(provideNodeLocationFlows, nodeNum) { val key = booleanPreferencesKey(provideLocationKey(nodeNum)) @@ -168,5 +203,10 @@ class UiPrefsImpl( val KEY_ONLY_DIRECT = booleanPreferencesKey("only-direct") val KEY_SHOW_IGNORED = booleanPreferencesKey("show-ignored") val KEY_EXCLUDE_MQTT = booleanPreferencesKey("exclude-mqtt") + val KEY_BLE_AUTO_SCAN = booleanPreferencesKey("ble-auto-scan") + val KEY_NETWORK_AUTO_SCAN = booleanPreferencesKey("network-auto-scan") + val KEY_SHOW_BLE_TRANSPORT = booleanPreferencesKey("show-ble-transport") + val KEY_SHOW_NETWORK_TRANSPORT = booleanPreferencesKey("show-network-transport") + val KEY_SHOW_USB_TRANSPORT = booleanPreferencesKey("show-usb-transport") } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index d7400332d..566465ba5 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -124,6 +124,31 @@ interface UiPrefs { fun setShowQuickChat(show: Boolean) + /** Whether BLE scanning should auto-start when the Connections screen is opened. */ + val bleAutoScan: StateFlow + + fun setBleAutoScan(enabled: Boolean) + + /** Whether NSD network scanning should auto-start when the Connections screen is opened. */ + val networkAutoScan: StateFlow + + fun setNetworkAutoScan(enabled: Boolean) + + /** Whether the BLE transport section is visible in the Connections device list. */ + val showBleTransport: StateFlow + + fun setShowBleTransport(enabled: Boolean) + + /** Whether the network (TCP/NSD) transport section is visible in the Connections device list. */ + val showNetworkTransport: StateFlow + + fun setShowNetworkTransport(enabled: Boolean) + + /** Whether the USB transport section is visible in the Connections device list. */ + val showUsbTransport: StateFlow + + fun setShowUsbTransport(enabled: Boolean) + fun shouldProvideNodeLocation(nodeNum: Int): StateFlow fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 2ef01ef24..0e8334a4b 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -129,6 +129,10 @@ Short Range - Turbo Short Range - Fast Short Range - Slow + Lite - Fast + Lite - Slow + Narrow - Fast + Narrow - Slow Enabling WiFi will disable the bluetooth connection to the app. Enabling Ethernet will disable the bluetooth connection to the app. TCP node connections are not available on Apple devices. @@ -199,6 +203,9 @@ No network devices found No USB devices found USB + BLE + TCP + USB Demo Mode Connected to radio, but it is sleeping Application update required @@ -941,7 +948,24 @@ Firmware Edition Recent Network Devices Discovered Network Devices + Scan for network devices + Scanning… + Scan for Bluetooth devices + Scanning… Available Bluetooth Devices + Add device manually… + No devices found + No Bluetooth devices seen + Ensure you\'re within range of the device. + No network devices seen + Ensure you\'re connected to the same network as the device. + No USB devices seen + Connect the device via serial or USB. + No device connected + Connect to a device to discover nearby nodes. + Searching for nodes + Nearby nodes will appear here as they\'re discovered. + Set up connection Get started Welcome to diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 0eb120fbe..c55bf8a28 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -150,6 +150,36 @@ class FakeUiPrefs : UiPrefs { showQuickChat.value = show } + override val bleAutoScan = MutableStateFlow(false) + + override fun setBleAutoScan(enabled: Boolean) { + bleAutoScan.value = enabled + } + + override val networkAutoScan = MutableStateFlow(false) + + override fun setNetworkAutoScan(enabled: Boolean) { + networkAutoScan.value = enabled + } + + override val showBleTransport = MutableStateFlow(true) + + override fun setShowBleTransport(enabled: Boolean) { + showBleTransport.value = enabled + } + + override val showNetworkTransport = MutableStateFlow(true) + + override fun setShowNetworkTransport(enabled: Boolean) { + showNetworkTransport.value = enabled + } + + override val showUsbTransport = MutableStateFlow(true) + + override fun setShowUsbTransport(enabled: Boolean) { + showUsbTransport.value = enabled + } + private val nodeLocationEnabled = mutableMapOf>() override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow = diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModelExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModelExtensions.kt index 767f0cbdf..4905c894d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModelExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModelExtensions.kt @@ -20,12 +20,16 @@ import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.model.ChannelOption import org.meshtastic.core.model.TracerouteMapAvailability import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.label_lite_fast +import org.meshtastic.core.resources.label_lite_slow import org.meshtastic.core.resources.label_long_fast import org.meshtastic.core.resources.label_long_moderate import org.meshtastic.core.resources.label_long_slow import org.meshtastic.core.resources.label_long_turbo import org.meshtastic.core.resources.label_medium_fast import org.meshtastic.core.resources.label_medium_slow +import org.meshtastic.core.resources.label_narrow_fast +import org.meshtastic.core.resources.label_narrow_slow import org.meshtastic.core.resources.label_short_fast import org.meshtastic.core.resources.label_short_slow import org.meshtastic.core.resources.label_short_turbo @@ -46,6 +50,10 @@ val ChannelOption.labelRes: StringResource ChannelOption.SHORT_FAST -> Res.string.label_short_fast ChannelOption.SHORT_SLOW -> Res.string.label_short_slow ChannelOption.SHORT_TURBO -> Res.string.label_short_turbo + ChannelOption.LITE_FAST -> Res.string.label_lite_fast + ChannelOption.LITE_SLOW -> Res.string.label_lite_slow + ChannelOption.NARROW_FAST -> Res.string.label_narrow_fast + ChannelOption.NARROW_SLOW -> Res.string.label_narrow_slow } fun TracerouteMapAvailability.toMessageRes(): StringResource? = when (this) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt index 75016084f..8e7efa50f 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt @@ -20,9 +20,11 @@ import androidx.lifecycle.ViewModel 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.map import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.repository.NodeRepository @@ -32,6 +34,27 @@ import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig +/** + * Derived, UI-friendly summary of the device connection state. Combines [ServiceRepository.connectionState] with + * "region unset" to surface the MUST_SET_REGION case that otherwise needs a separate boolean flag in the UI layer. + */ +enum class ConnectionStatus { + /** No device has been selected or we are otherwise disconnected. */ + NOT_CONNECTED, + + /** A device has been selected and we are working through bonding/handshake. */ + CONNECTING, + + /** Connected with node info available. */ + CONNECTED, + + /** Connected but the device is in deep sleep. */ + CONNECTED_SLEEPING, + + /** Connected and active, but LoRa region is UNSET — user action required. */ + MUST_SET_REGION, +} + @KoinViewModel class ConnectionsViewModel( radioConfigRepository: RadioConfigRepository, @@ -71,6 +94,23 @@ class ConnectionsViewModel( .distinctUntilChanged() .stateInWhileSubscribed(initialValue = false) + /** + * Single source of truth for the UI's "connection status" pill/banner. Derived from [connectionState] and + * [regionUnset]; kept here rather than in the composable so the mapping is observable and testable. + */ + val connectionStatus: StateFlow = + combine(connectionState, regionUnset) { state, unset -> + when (state) { + is ConnectionState.Connected -> + if (unset) ConnectionStatus.MUST_SET_REGION else ConnectionStatus.CONNECTED + ConnectionState.Connecting -> ConnectionStatus.CONNECTING + ConnectionState.Disconnected -> ConnectionStatus.NOT_CONNECTED + ConnectionState.DeviceSleep -> ConnectionStatus.CONNECTED_SLEEPING + } + } + .distinctUntilChanged() + .stateInWhileSubscribed(initialValue = ConnectionStatus.NOT_CONNECTED) + private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value) val hasShownNotPairedWarning: StateFlow = _hasShownNotPairedWarning.asStateFlow() diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 026f0a100..d02dc3531 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -283,7 +283,15 @@ private fun ApplicationScope.MeshtasticWindow( windowState: WindowState, onCloseRequest: () -> Unit, ) { - val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) + val multiBackstack = + rememberMultiBackstack( + // Land on Connections for first-run / no-device-selected; otherwise on Nodes. + if (uiViewModel.currentDeviceAddressFlow.value.let { it.isNullOrBlank() || it == "n" }) { + TopLevelDestination.Connections.route + } else { + TopLevelDestination.Nodes.route + }, + ) Window( onCloseRequest = onCloseRequest, diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index 594a62bc4..8209bc633 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -19,6 +19,8 @@ package org.meshtastic.desktop.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import org.meshtastic.core.navigation.MultiBackstack +import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph @@ -35,11 +37,16 @@ import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph * Each call delegates to the shared navigation graph extension exported by the corresponding feature module, keeping * the desktop shell free of screen-level composable knowledge. */ -fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack, uiViewModel: UIViewModel) { +fun EntryProviderScope.desktopNavGraph( + backStack: NavBackStack, + uiViewModel: UIViewModel, + multiBackstack: MultiBackstack, +) { nodesGraph( backStack = backStack, scrollToTopEvents = uiViewModel.scrollToTopEventFlow, onHandleDeepLink = uiViewModel::handleDeepLink, + onNavigateToConnections = { multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) }, ) contactsGraph(backStack, uiViewModel.scrollToTopEventFlow) mapGraph(backStack) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index a55bf902f..9b38bc164 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -50,7 +50,7 @@ fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) uiViewModel = uiViewModel, modifier = Modifier.fillMaxSize(), ) { - val provider = entryProvider { desktopNavGraph(backStack, uiViewModel) } + val provider = entryProvider { desktopNavGraph(backStack, uiViewModel, multiBackstack) } MeshtasticNavDisplay( multiBackstack = multiBackstack, entryProvider = provider, diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt index 3278812fb..bf740baf7 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt @@ -27,10 +27,12 @@ import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.util.anonymize +import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.feature.connections.model.AndroidUsbDeviceData import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase @@ -44,9 +46,11 @@ class AndroidScannerViewModel( radioPrefs: RadioPrefs, recentAddressesDataSource: RecentAddressesDataSource, getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase, + networkRepository: NetworkRepository, dispatchers: org.meshtastic.core.di.CoroutineDispatchers, private val bluetoothRepository: BluetoothRepository, private val usbRepository: UsbRepository, + uiPrefs: UiPrefs, bleScanner: org.meshtastic.core.ble.BleScanner? = null, ) : ScannerViewModel( serviceRepository, @@ -55,7 +59,9 @@ class AndroidScannerViewModel( radioPrefs, recentAddressesDataSource, getDiscoveredDevicesUseCase, + networkRepository, dispatchers, + uiPrefs, bleScanner, ) { override fun requestBonding(entry: DeviceListEntry.Ble) { 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 b6999aadc..72aa57b51 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 @@ -28,7 +28,6 @@ import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.model.Node import org.meshtastic.core.network.repository.DiscoveredService -import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioInterfaceService @@ -46,7 +45,6 @@ import java.util.Locale @Single class AndroidGetDiscoveredDevicesUseCase( private val bluetoothRepository: BluetoothRepository, - private val networkRepository: NetworkRepository, private val recentAddressesDataSource: RecentAddressesDataSource, private val nodeRepository: NodeRepository, private val databaseManager: DatabaseManager, @@ -57,7 +55,7 @@ class AndroidGetDiscoveredDevicesUseCase( private val macSuffixLength = 8 @Suppress("LongMethod", "CyclomaticComplexMethod") - override fun invoke(showMock: Boolean): Flow { + override fun invoke(showMock: Boolean, resolvedList: Flow>): Flow { val nodeDb = nodeRepository.nodeDBbyNum // Filter out non-Meshtastic peripherals (headphones, cars, watches, etc.). @@ -70,10 +68,7 @@ class AndroidGetDiscoveredDevicesUseCase( } val processedTcpFlow = - combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { - tcpServices, - recentList, - -> + combine(resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList -> val defaultName = getString(Res.string.meshtastic) processTcpServices(tcpServices, recentList, defaultName) } @@ -99,7 +94,7 @@ class AndroidGetDiscoveredDevicesUseCase( bondedBleFlow, processedTcpFlow, usbDevicesFlow, - networkRepository.resolvedList, + resolvedList, recentAddressesDataSource.recentAddresses, ) { args: Array -> @Suppress("UNCHECKED_CAST", "MagicNumber") @@ -120,40 +115,9 @@ class AndroidGetDiscoveredDevicesUseCase( @Suppress("UNCHECKED_CAST", "MagicNumber") val recentList = args[5] as List - // Android-specific: BLE node matching by MAC suffix and Meshtastic short name - val bleForUi = - bondedBle - .map { entry -> - val matchingNode = - if (databaseManager.hasDatabaseFor(entry.fullAddress)) { - db.values.find { node -> - val macSuffix = - entry.device.address - .replace(":", "") - .takeLast(macSuffixLength) - .lowercase(Locale.ROOT) - val nameSuffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT) - node.user.id.lowercase(Locale.ROOT).endsWith(macSuffix) || - (nameSuffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(nameSuffix)) - } - } else { - null - } - entry.copy(node = matchingNode) - } - .sortedBy { it.name } + val bleForUi = matchBleNodes(bondedBle, db) + val usbForUi = matchUsbNodes(usbDevices, showMock, db) - // Android-specific: USB node matching via shared helper - val usbForUi = - ( - usbDevices + - if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList() - ) - .map { entry -> - entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager)) - } - - // Shared TCP logic via helpers val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager) val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet() val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager) @@ -166,4 +130,33 @@ class AndroidGetDiscoveredDevicesUseCase( ) } } + + private fun matchBleNodes(bondedBle: List, db: Map): List = + bondedBle + .map { entry -> + val matchingNode = + if (databaseManager.hasDatabaseFor(entry.fullAddress)) { + db.values.find { node -> + val macSuffix = + entry.device.address.replace(":", "").takeLast(macSuffixLength).lowercase(Locale.ROOT) + val nameSuffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT) + node.user.id.lowercase(Locale.ROOT).endsWith(macSuffix) || + (nameSuffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(nameSuffix)) + } + } else { + null + } + entry.copy(node = matchingNode) + } + .sortedBy { it.name } + + private suspend fun matchUsbNodes( + usbDevices: List, + showMock: Boolean, + db: Map, + ): List = + (usbDevices + if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()) + .map { entry -> + entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager)) + } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/Constants.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/Constants.kt new file mode 100644 index 000000000..551f873d7 --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/Constants.kt @@ -0,0 +1,33 @@ +/* + * 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.feature.connections + +/** + * Sentinel value for "no device selected" that mirrors the one-char prefix used by every real + * [org.meshtastic.feature.connections.model.DeviceListEntry]'s `fullAddress`. Persisted by `radioInterfaceService` so + * the UI layer can distinguish "user explicitly disconnected" from "first launch". + */ +const val NO_DEVICE_SELECTED: String = "n" + +/** One-char prefix marking a TCP/Network device's `fullAddress`. See [DeviceListEntry.Tcp]. */ +const val TCP_DEVICE_PREFIX: String = "t" + +/** One-char prefix / sentinel marking the mock (demo-mode) device's `fullAddress`. See [DeviceListEntry.Mock]. */ +const val MOCK_DEVICE_PREFIX: String = "m" + +/** One-char prefix marking a BLE device's `fullAddress`. See [DeviceListEntry.Ble]. */ +const val BLE_DEVICE_PREFIX: String = "x" diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 7e57f2eff..39532fd17 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -19,28 +19,46 @@ package org.meshtastic.feature.connections import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job 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.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import org.meshtastic.core.ble.BleDevice +import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.ble.MeshtasticBleConstants import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.util.anonymize +import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.connections.model.DeviceListEntry +import org.meshtastic.feature.connections.model.DiscoveredDevices import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase +import kotlin.time.Duration +/** + * Platform-neutral ViewModel that drives the Connections screen: device discovery (BLE/USB/TCP), scan state, current + * selection, and connection-progress chatter. + * + * Subclassed per-platform (see `AndroidScannerViewModel`, `JvmScannerViewModel`) to plug in platform-specific bonding / + * permission flows. + */ @Suppress("LongParameterList", "TooManyFunctions") open class ScannerViewModel( protected val serviceRepository: ServiceRepository, @@ -49,48 +67,189 @@ open class ScannerViewModel( private val radioPrefs: RadioPrefs, private val recentAddressesDataSource: RecentAddressesDataSource, private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase, - private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers, - private val bleScanner: org.meshtastic.core.ble.BleScanner? = null, + private val networkRepository: NetworkRepository, + private val dispatchers: CoroutineDispatchers, + private val uiPrefs: UiPrefs, + private val bleScanner: BleScanner? = null, ) : ViewModel() { + + // ── Mock / demo transport ───────────────────────────────────────────────────────────────── private val _showMockTransport = MutableStateFlow(false) val showMockTransport: StateFlow = _showMockTransport.asStateFlow() - private val _errorText = MutableStateFlow(null) - val errorText: StateFlow = _errorText.asStateFlow() + // ── Connection-progress chatter (surfaced as the bottom status pill) ────────────────────── + private val _connectionProgressText = MutableStateFlow(null) - private val isBleScanningState = MutableStateFlow(false) - val isBleScanning: StateFlow = isBleScanningState.asStateFlow() + /** + * Transient, fine-grained status text emitted during connect/bonding (e.g. "Bonding…", "Requesting config…"). + * Nullable because `serviceRepository.connectionProgress` does not emit during steady-state. + * + * Persistent "Not connected / Connecting / Connected" copy is derived separately in + * `ConnectionsViewModel.connectionStatus` so the UI can choose `progress ?: status`. + */ + val connectionProgressText: StateFlow = _connectionProgressText.asStateFlow() - private val scannedBleDevices = MutableStateFlow>(emptyMap()) + /** + * Back-compat alias for [connectionProgressText]. Kept so existing screens/tests don't need a synchronised rename + * in the same commit. + */ + @Deprecated("Use connectionProgressText", ReplaceWith("connectionProgressText")) + val errorText: StateFlow + get() = connectionProgressText - private var scanJob: kotlinx.coroutines.Job? = null + // ── BLE scanning ────────────────────────────────────────────────────────────────────────── + private val _isBleScanning = MutableStateFlow(false) + val isBleScanning: StateFlow = _isBleScanning.asStateFlow() + + /** User preference that controls whether BLE scanning auto-starts when the Connections screen opens. */ + val bleAutoScan: StateFlow = uiPrefs.bleAutoScan + + private val scannedBleDevices = MutableStateFlow>(emptyMap()) + private var scanJob: Job? = null + + // ── Network scanning (NSD gating) ───────────────────────────────────────────────────────── + private val _isNetworkScanning = MutableStateFlow(false) + val isNetworkScanning: StateFlow = _isNetworkScanning.asStateFlow() + + /** User preference that controls whether NSD network scanning auto-starts when the Connections screen opens. */ + val networkAutoScan: StateFlow = uiPrefs.networkAutoScan + + // ── Transport-section visibility (filter chips) ─────────────────────────────────────────── + + /** Whether the BLE section is visible in the Connections device list. Defaults to `true`. */ + val showBleTransport: StateFlow = uiPrefs.showBleTransport + + /** Whether the Network (TCP/NSD) section is visible in the Connections device list. Defaults to `true`. */ + val showNetworkTransport: StateFlow = uiPrefs.showNetworkTransport + + /** Whether the USB section is visible in the Connections device list. Defaults to `true`. */ + val showUsbTransport: StateFlow = uiPrefs.showUsbTransport + + fun setShowBleTransport(enabled: Boolean) = uiPrefs.setShowBleTransport(enabled) + + fun setShowNetworkTransport(enabled: Boolean) = uiPrefs.setShowNetworkTransport(enabled) + + fun setShowUsbTransport(enabled: Boolean) = uiPrefs.setShowUsbTransport(enabled) + + /** + * Resolved NSD services flow, gated by [_isNetworkScanning]. When scanning is inactive, emits `emptyList()` so + * `NsdManager.discoverServices()` is never triggered. Android 15+ shows a system consent dialog the first time + * `resolvedList` is subscribed, so the gate ensures NSD only runs when the user explicitly requests it. + */ + private val gatedResolvedList = + _isNetworkScanning.flatMapLatest { scanning -> + if (scanning) networkRepository.resolvedList else flowOf(emptyList()) + } + + private val discoveredDevicesFlow: StateFlow = + showMockTransport + .flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock, gatedResolvedList) } + .stateInWhileSubscribed(initialValue = DiscoveredDevices()) init { _showMockTransport.value = radioInterfaceService.isMockTransport() + serviceRepository.connectionProgress.onEach { _connectionProgressText.value = it }.launchIn(viewModelScope) + Logger.d { "ScannerViewModel created" } } - fun startBleScan() { - if (isBleScanningState.value || bleScanner == null) return + override fun onCleared() { + super.onCleared() + stopBleScan() + stopNetworkScan() + Logger.d { "ScannerViewModel cleared" } + } - isBleScanningState.value = true + // ── Device lists for UI ────────────────────────────────────────────────────────────────── + + /** + * Combined bonded + scanned BLE devices for the UI. + * + * Sorted by signal strength — scanned devices with a known RSSI appear first in descending order (strongest signal + * at the top), followed by bonded-only devices without a scan RSSI, sorted by name. + */ + val bleDevicesForUi: StateFlow> = + combine(discoveredDevicesFlow, scannedBleDevices) { discovered, scannedMap -> + val bonded = discovered.bleDevices.filterIsInstance() + val bondedAddresses = bonded.mapTo(mutableSetOf()) { it.address } + + // Scanned-but-not-bonded devices are explicitly flagged unbonded so the UI routes through + // requestBonding() — which on Android triggers createBond() for the pairing dialog before connecting. + val unbondedScanned = + scannedMap.values + .asSequence() + .filter { it.address !in bondedAddresses } + .map { DeviceListEntry.Ble(device = it, bonded = false) } + + // For bonded devices, attach the latest scan RSSI (if we've seen an advertisement this session) so they + // sort alongside unbonded entries by signal strength. + val bondedWithRssi = + bonded.asSequence().map { entry -> + val scanned = scannedMap[entry.address] + if (scanned != null && scanned.rssi != null) entry.copy(device = scanned) else entry + } + + (bondedWithRssi + unbondedScanned) + .sortedWith( + compareByDescending { it.device.rssi != null } + .thenByDescending { it.device.rssi ?: Int.MIN_VALUE } + .thenBy { it.name }, + ) + .toList() + } + .flowOn(dispatchers.default) + .distinctUntilChanged() + .stateInWhileSubscribed(initialValue = emptyList()) + + val usbDevicesForUi: StateFlow> = + discoveredDevicesFlow.map { it.usbDevices }.distinctUntilChanged().stateInWhileSubscribed(emptyList()) + + val discoveredTcpDevicesForUi: StateFlow> = + discoveredDevicesFlow.map { it.discoveredTcpDevices }.distinctUntilChanged().stateInWhileSubscribed(emptyList()) + + val recentTcpDevicesForUi: StateFlow> = + discoveredDevicesFlow.map { it.recentTcpDevices }.distinctUntilChanged().stateInWhileSubscribed(emptyList()) + + // ── Current selection ──────────────────────────────────────────────────────────────────── + + val selectedAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow + + /** The persisted device name from the last selection, for use as a UI fallback. */ + val persistedDeviceName: StateFlow = radioPrefs.devName + + /** Non-null variant of [selectedAddressFlow] that substitutes [NO_DEVICE_SELECTED] for `null`. */ + val selectedNotNullFlow: StateFlow = + selectedAddressFlow + .map { it ?: NO_DEVICE_SELECTED } + .stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED) + + // ── Scan commands ──────────────────────────────────────────────────────────────────────── + + fun startBleScan() { + if (_isBleScanning.value || bleScanner == null) return + + _isBleScanning.value = true scannedBleDevices.value = emptyMap() scanJob = safeLaunch(tag = "startBleScan") { try { bleScanner - .scan( - timeout = kotlin.time.Duration.INFINITE, - serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID, - ) + .scan(timeout = Duration.INFINITE, serviceUuid = MeshtasticBleConstants.SERVICE_UUID) .flowOn(dispatchers.io) .collect { device -> - if (!scannedBleDevices.value.containsKey(device.address)) { - scannedBleDevices.update { current -> current + (device.address to device) } + scannedBleDevices.update { current -> + val existing = current[device.address] + // Replace if RSSI changed so the UI reflects the latest advertisement. Keep the same + // instance otherwise to avoid unnecessary recomposition. + if (existing != null && existing.rssi == device.rssi) { + current + } else { + current + (device.address to device) + } } } } finally { - isBleScanningState.value = false + _isBleScanning.value = false } } } @@ -98,91 +257,49 @@ open class ScannerViewModel( fun stopBleScan() { scanJob?.cancel() scanJob = null - isBleScanningState.value = false + _isBleScanning.value = false + // Drop cached advertisements so stale RSSI values don't linger in the UI after the scan ends. + scannedBleDevices.value = emptyMap() } - private val discoveredDevicesFlow = - showMockTransport - .flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) } - .stateInWhileSubscribed(initialValue = null) - - /** A combined list of bonded and scanned BLE devices for the UI. */ - val bleDevicesForUi: StateFlow> = - kotlinx.coroutines.flow - .combine(discoveredDevicesFlow, scannedBleDevices) { discovered, scannedMap -> - val bonded = discovered?.bleDevices?.filterIsInstance() ?: emptyList() - val bondedAddresses = bonded.map { it.address }.toSet() - - // Add scanned devices that aren't already in the bonded list. - // These are explicitly marked as unbonded so the UI routes through - // requestBonding() — which on Android triggers createBond() for the - // pairing dialog before connecting. - val unbondedScanned = - scannedMap.values - .filter { it.address !in bondedAddresses } - .map { DeviceListEntry.Ble(device = it, bonded = false) } - - // Sort by name - (bonded + unbondedScanned).sortedBy { it.name } - } - .flowOn(dispatchers.default) - .distinctUntilChanged() - .stateInWhileSubscribed(initialValue = emptyList()) - - /** UI StateFlow for USB devices. */ - val usbDevicesForUi: StateFlow> = - discoveredDevicesFlow - .map { it?.usbDevices ?: emptyList() } - .distinctUntilChanged() - .stateInWhileSubscribed(initialValue = emptyList()) - - /** UI StateFlow for discovered TCP devices (NSD). */ - val discoveredTcpDevicesForUi: StateFlow> = - discoveredDevicesFlow - .map { it?.discoveredTcpDevices ?: emptyList() } - .distinctUntilChanged() - .stateInWhileSubscribed(initialValue = emptyList()) - - /** UI StateFlow for recent TCP devices. */ - val recentTcpDevicesForUi: StateFlow> = - discoveredDevicesFlow - .map { it?.recentTcpDevices ?: emptyList() } - .distinctUntilChanged() - .stateInWhileSubscribed(initialValue = emptyList()) - - val selectedAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow - - /** The persisted device name from the last selection, for use as a UI fallback. */ - val persistedDeviceName: StateFlow = radioPrefs.devName - - val selectedNotNullFlow: StateFlow = - selectedAddressFlow - .map { it ?: NO_DEVICE_SELECTED } - .stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED) - - val supportedDeviceTypes: List = radioInterfaceService.supportedDeviceTypes - - init { - serviceRepository.connectionProgress.onEach { _errorText.value = it }.launchIn(viewModelScope) - Logger.d { "ScannerViewModel created" } + /** Convenience command: start scanning if idle, stop otherwise. Persists the resulting state to prefs. */ + fun toggleBleScan() { + if (_isBleScanning.value) stopBleScan() else startBleScan() + uiPrefs.setBleAutoScan(_isBleScanning.value) } - override fun onCleared() { - super.onCleared() - Logger.d { "ScannerViewModel cleared" } + fun startNetworkScan() { + _isNetworkScanning.value = true } - fun setErrorText(text: String) { - _errorText.value = text + fun stopNetworkScan() { + _isNetworkScanning.value = false } + /** Convenience command: start scanning if idle, stop otherwise. Persists the resulting state to prefs. */ + fun toggleNetworkScan() { + if (_isNetworkScanning.value) stopNetworkScan() else startNetworkScan() + uiPrefs.setNetworkAutoScan(_isNetworkScanning.value) + } + + /** + * Persist the user's intent to auto-scan the network on next screen entry without flipping the active scan flag. + * Used by the Connections screen when it must defer the actual scan start until after the system permission grant + * dialog resolves — the persisted intent ensures auto-start fires once permission is granted. + */ + fun persistNetworkAutoScanIntent(enabled: Boolean) { + uiPrefs.setNetworkAutoScan(enabled) + } + + // ── Device selection / disconnect ─────────────────────────────────────────────────────── + fun changeDeviceAddress(address: String) { Logger.i { "Attempting to change device address to ${address.anonymize()}" } radioController.setDeviceAddress(address) } fun addRecentAddress(address: String, name: String) { - if (!address.startsWith("t")) return + if (!address.startsWith(TCP_DEVICE_PREFIX)) return safeLaunch(tag = "addRecentAddress") { recentAddressesDataSource.add(RecentAddress(address, name)) } } @@ -191,46 +308,44 @@ open class ScannerViewModel( } /** - * Called by the GUI when a new device has been selected by the user. + * Called by the UI when a device has been tapped. BLE and USB entries may still need bonding/permission — the + * concrete return value tells the caller whether the connection was initiated immediately. * - * @return true if the connection was initiated immediately. + * @return `true` if the connection has been initiated; `false` if bonding/permission is pending. */ - fun onSelected(it: DeviceListEntry): Boolean = when (it) { - is DeviceListEntry.Ble -> { - if (it.bonded) { - radioPrefs.setDevName(it.name) - changeDeviceAddress(it.fullAddress) + fun onSelected(entry: DeviceListEntry): Boolean { + radioPrefs.setDevName(entry.name) + return when (entry) { + is DeviceListEntry.Ble -> { + if (entry.bonded) { + changeDeviceAddress(entry.fullAddress) + true + } else { + requestBonding(entry) + false + } + } + is DeviceListEntry.Usb -> { + if (entry.bonded) { + changeDeviceAddress(entry.fullAddress) + true + } else { + requestPermission(entry) + false + } + } + is DeviceListEntry.Tcp -> { + safeLaunch(tag = "onSelectedTcp") { + addRecentAddress(entry.fullAddress, entry.name) + changeDeviceAddress(entry.fullAddress) + } true - } else { - radioPrefs.setDevName(it.name) - requestBonding(it) - false } - } - is DeviceListEntry.Usb -> { - if (it.bonded) { - radioPrefs.setDevName(it.name) - changeDeviceAddress(it.fullAddress) + is DeviceListEntry.Mock -> { + changeDeviceAddress(entry.fullAddress) true - } else { - radioPrefs.setDevName(it.name) - requestPermission(it) - false } } - is DeviceListEntry.Tcp -> { - safeLaunch(tag = "onSelectedTcp") { - radioPrefs.setDevName(it.name) - addRecentAddress(it.fullAddress, it.name) - changeDeviceAddress(it.fullAddress) - } - true - } - is DeviceListEntry.Mock -> { - radioPrefs.setDevName(it.name) - changeDeviceAddress(it.fullAddress) - true - } } /** @@ -244,12 +359,10 @@ open class ScannerViewModel( changeDeviceAddress(entry.fullAddress) } - protected open fun requestPermission(entry: DeviceListEntry.Usb) {} + protected open fun requestPermission(entry: DeviceListEntry.Usb) = Unit fun disconnect() { radioPrefs.setDevName(null) changeDeviceAddress(NO_DEVICE_SELECTED) } } - -const val NO_DEVICE_SELECTED = "n" diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt index 4249cd625..582643034 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt @@ -18,11 +18,11 @@ package org.meshtastic.feature.connections.domain.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import org.koin.core.annotation.Single +import kotlinx.coroutines.flow.flowOf import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.safeCatchingAll import org.meshtastic.core.datastore.RecentAddressesDataSource -import org.meshtastic.core.network.repository.NetworkRepository +import org.meshtastic.core.network.repository.DiscoveredService import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.demo_mode @@ -32,51 +32,49 @@ import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.DiscoveredDevices import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase -@Single -class CommonGetDiscoveredDevicesUseCase( +/** + * Platform-agnostic implementation of [GetDiscoveredDevicesUseCase]. + * + * Intentionally NOT annotated `@Single` in common source: on Android, the richer + * [org.meshtastic.feature.connections.domain.usecase.AndroidGetDiscoveredDevicesUseCase] is the canonical binding, and + * a common `@Single` here would silently override it (last-write-wins), producing an empty USB list. Each non-Android + * target registers its own `@Single` wrapper (see `JvmGetDiscoveredDevicesUseCase`). + */ +open class CommonGetDiscoveredDevicesUseCase( private val recentAddressesDataSource: RecentAddressesDataSource, private val nodeRepository: NodeRepository, private val databaseManager: DatabaseManager, - private val networkRepository: NetworkRepository, private val usbScanner: UsbScanner? = null, ) : GetDiscoveredDevicesUseCase { - override fun invoke(showMock: Boolean): Flow { + override fun invoke(showMock: Boolean, resolvedList: Flow>): Flow { val nodeDb = nodeRepository.nodeDBbyNum - val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList()) + val usbFlow = usbScanner?.scanUsbDevices() ?: flowOf(emptyList()) - val processedTcpFlow = - combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { - tcpServices, - recentList, - -> - val defaultName = safeCatchingAll { getStringSuspend(Res.string.meshtastic) }.getOrDefault("Meshtastic") - processTcpServices(tcpServices, recentList, defaultName) - } + return combine(nodeDb, resolvedList, recentAddressesDataSource.recentAddresses, usbFlow) { + db, + resolved, + recentList, + usbList, + -> + val defaultName = safeCatchingAll { getStringSuspend(Res.string.meshtastic) }.getOrDefault("Meshtastic") + val processedTcp = processTcpServices(resolved, recentList, defaultName) + val discoveredTcpAddresses = processedTcp.mapTo(mutableSetOf()) { it.fullAddress } - return combine( - nodeDb, - processedTcpFlow, - networkRepository.resolvedList, - recentAddressesDataSource.recentAddresses, - usbFlow, - ) { db, processedTcp, resolved, recentList, usbList -> val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager) - val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet() val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager) + val mockEntries = buildList { + if (showMock) { + val label = safeCatchingAll { getStringSuspend(Res.string.demo_mode) }.getOrDefault("Demo Mode") + add(DeviceListEntry.Mock(label)) + } + } + DiscoveredDevices( discoveredTcpDevices = discoveredTcpForUi, recentTcpDevices = recentTcpForUi, - usbDevices = - usbList + - if (showMock) { - val demoModeLabel = - safeCatchingAll { getStringSuspend(Res.string.demo_mode) }.getOrDefault("Demo Mode") - listOf(DeviceListEntry.Mock(demoModeLabel)) - } else { - emptyList() - }, + usbDevices = usbList + mockEntries, ) } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt index ee01872c0..5eb49b8b9 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.connections.model import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.network.repository.DiscoveredService data class DiscoveredDevices( val bleDevices: List = emptyList(), @@ -26,5 +27,12 @@ data class DiscoveredDevices( ) interface GetDiscoveredDevicesUseCase { - fun invoke(showMock: Boolean): Flow + /** + * Returns a flow of all discovered devices (BLE, USB, TCP). + * + * @param resolvedList the NSD/mDNS resolved services flow. On Android 15+, subscribing to + * `NetworkRepository.resolvedList` triggers a system consent dialog, so callers should pass `flowOf(emptyList())` + * unless the user has explicitly requested a network scan. + */ + fun invoke(showMock: Boolean, resolvedList: Flow>): Flow } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 7fdc287cd..c12b7e7a3 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -16,7 +16,11 @@ */ package org.meshtastic.feature.connections.ui -import androidx.compose.animation.Crossfade +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,68 +28,68 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.size import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DeviceType import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.connected -import org.meshtastic.core.resources.connected_device -import org.meshtastic.core.resources.connected_sleeping -import org.meshtastic.core.resources.connecting import org.meshtastic.core.resources.connections -import org.meshtastic.core.resources.must_set_region import org.meshtastic.core.resources.no_device_selected -import org.meshtastic.core.resources.not_connected import org.meshtastic.core.resources.set_your_region import org.meshtastic.core.resources.unknown_device +import org.meshtastic.core.ui.component.AdaptiveTwoPane import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.icon.Language import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.NoDevice +import org.meshtastic.core.ui.util.isLocalNetworkPermissionGranted +import org.meshtastic.core.ui.util.rememberRequestLocalNetworkPermission +import org.meshtastic.core.ui.viewmodel.ConnectionStatus import org.meshtastic.core.ui.viewmodel.ConnectionsViewModel +import org.meshtastic.feature.connections.MOCK_DEVICE_PREFIX import org.meshtastic.feature.connections.NO_DEVICE_SELECTED import org.meshtastic.feature.connections.ScannerViewModel +import org.meshtastic.feature.connections.TCP_DEVICE_PREFIX import org.meshtastic.feature.connections.model.DeviceListEntry -import org.meshtastic.feature.connections.ui.components.BLEDevices import org.meshtastic.feature.connections.ui.components.ConnectingDeviceInfo -import org.meshtastic.feature.connections.ui.components.ConnectionsSegmentedBar import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedInfo -import org.meshtastic.feature.connections.ui.components.EmptyStateContent -import org.meshtastic.feature.connections.ui.components.NetworkDevices -import org.meshtastic.feature.connections.ui.components.UsbDevices +import org.meshtastic.feature.connections.ui.components.DeviceList +import org.meshtastic.feature.connections.ui.components.TransportFilterChips import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.getNavRouteFrom import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog import kotlin.uuid.ExperimentalUuidApi +/** + * Fixed minimum height for the "connected device" card at the top of the Connections screen. Shared across the three UI + * states (NO_DEVICE, CONNECTING, CONNECTED_WITH_NODE) so the card never collapses or jumps size between state + * transitions. Sized to comfortably fit the CONNECTED state (battery/RSSI row + node row + disconnect button). + */ +private val CardMinHeight = 100.dp + /** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber", "ModifierMissing", "ComposableParamOrder") @@ -99,7 +103,8 @@ fun ConnectionsScreen( onConfigNavigate: (Route) -> Unit, ) { val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle() - val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle() + val connectionProgress by scanModel.connectionProgressText.collectAsStateWithLifecycle() + val connectionStatus by connectionsViewModel.connectionStatus.collectAsStateWithLifecycle() val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle() val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle() val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle() @@ -111,6 +116,36 @@ fun ConnectionsScreen( val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle() val recentTcpDevices by scanModel.recentTcpDevicesForUi.collectAsStateWithLifecycle() val usbDevices by scanModel.usbDevicesForUi.collectAsStateWithLifecycle() + val isBleScanning by scanModel.isBleScanning.collectAsStateWithLifecycle() + val isNetworkScanning by scanModel.isNetworkScanning.collectAsStateWithLifecycle() + + val bleAutoScan by scanModel.bleAutoScan.collectAsStateWithLifecycle() + val networkAutoScan by scanModel.networkAutoScan.collectAsStateWithLifecycle() + val showBleTransport by scanModel.showBleTransport.collectAsStateWithLifecycle() + val showNetworkTransport by scanModel.showNetworkTransport.collectAsStateWithLifecycle() + val showUsbTransport by scanModel.showUsbTransport.collectAsStateWithLifecycle() + val localNetworkPermissionGranted = isLocalNetworkPermissionGranted() + + // Android 17 (API 37) gates NSD/mDNS behind ACCESS_LOCAL_NETWORK. Without this prompt the platform + // falls back to the system "Choose a device to connect" picker on every discoverServices() call. + // Granting the permission upfront lets discovery run silently in-app. + val requestLocalNetworkPermission = + rememberRequestLocalNetworkPermission( + onGranted = { scanModel.startNetworkScan() }, + onDenied = { scanModel.stopNetworkScan() }, + ) + + // Auto-start scans on screen entry when the user has previously opted in via the toggle. Stop on exit so we don't + // drain battery in the background. Network auto-start is additionally gated on the runtime local-network grant so + // we don't trigger the system picker for users who declined the permission. + DisposableEffect(localNetworkPermissionGranted) { + if (bleAutoScan) scanModel.startBleScan() + if (networkAutoScan && localNetworkPermissionGranted) scanModel.startNetworkScan() + onDispose { + scanModel.stopBleScan() + scanModel.stopNetworkScan() + } + } /* Animate waiting for the configurations */ var isWaiting by remember { mutableStateOf(false) } @@ -133,19 +168,6 @@ fun ConnectionsScreen( ) } - LaunchedEffect(connectionState, regionUnset) { - when (connectionState) { - ConnectionState.Connected -> { - if (regionUnset) Res.string.must_set_region else Res.string.connected - } - - ConnectionState.Connecting -> Res.string.connecting - - ConnectionState.Disconnected -> Res.string.not_connected - ConnectionState.DeviceSleep -> Res.string.connected_sleeping - }.let { scanModel.setErrorText(getString(it)) } - } - Scaffold( topBar = { MainAppBar( @@ -165,180 +187,170 @@ fun ConnectionsScreen( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Spacer(modifier = Modifier.height(4.dp)) - val uiState = - when { - connectionState is ConnectionState.Connected && ourNode != null -> - ConnectionUiState.CONNECTED_WITH_NODE - connectionState is ConnectionState.Connected || - connectionState == ConnectionState.Connecting || - selectedDevice != NO_DEVICE_SELECTED -> ConnectionUiState.CONNECTING + AdaptiveTwoPane( + first = { + val uiState = + when { + connectionState is ConnectionState.Connected && ourNode != null -> + ConnectionUiState.CONNECTED_WITH_NODE - else -> ConnectionUiState.NO_DEVICE - } + connectionState is ConnectionState.Connected || + connectionState == ConnectionState.Connecting || + selectedDevice != NO_DEVICE_SELECTED -> ConnectionUiState.CONNECTING - Crossfade(targetState = uiState, label = "connection_state") { state -> - when (state) { - ConnectionUiState.CONNECTED_WITH_NODE -> - ConnectedDeviceContent( - ourNode = ourNode, - regionUnset = regionUnset, - selectedDevice = selectedDevice, - bleDevices = bleDevices, - onNavigateToNodeDetails = onNavigateToNodeDetails, - onClickDisconnect = { scanModel.disconnect() }, - onSetRegion = { - isWaiting = true - radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) - }, - ) + else -> ConnectionUiState.NO_DEVICE + } - ConnectionUiState.CONNECTING -> - ConnectingDeviceContent( + // ── Connected Device slot ── + // A single Card shell hosts all three states. `animateContentSize` smooths any + // height changes, while `heightIn(min = CardMinHeight)` reserves a stable floor so + // the card never collapses between states. + Card(modifier = Modifier.fillMaxWidth().animateContentSize()) { + AnimatedContent( + targetState = uiState, + label = "connection_state", + transitionSpec = { fadeIn() togetherWith fadeOut() }, + modifier = Modifier.fillMaxWidth(), + ) { state -> + Box( + modifier = Modifier.fillMaxWidth().heightIn(min = CardMinHeight), + contentAlignment = Alignment.Center, + ) { + when (state) { + ConnectionUiState.CONNECTED_WITH_NODE -> + ConnectedDeviceContent( + ourNode = ourNode, + selectedDevice = selectedDevice, + bleDevices = bleDevices, + onNavigateToNodeDetails = onNavigateToNodeDetails, + onClickDisconnect = { scanModel.disconnect() }, + ) + + ConnectionUiState.CONNECTING -> + ConnectingDeviceContent( + selectedDevice = selectedDevice, + persistedDeviceName = persistedDeviceName, + bleDevices = bleDevices, + discoveredTcpDevices = discoveredTcpDevices, + recentTcpDevices = recentTcpDevices, + usbDevices = usbDevices, + connectionStatus = connectionStatus, + connectionProgress = connectionProgress, + onClickDisconnect = { scanModel.disconnect() }, + ) + + else -> NoDeviceContent() + } + } + } + } + + // Region warning sits outside the animated card so it does not affect the + // CONNECTED ↔ CONNECTING ↔ NO_DEVICE size transition. + if ( + uiState == ConnectionUiState.CONNECTED_WITH_NODE && + regionUnset && + selectedDevice != MOCK_DEVICE_PREFIX + ) { + Spacer(modifier = Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingIcon = MeshtasticIcons.Language, + text = stringResource(Res.string.set_your_region), + onClick = { + isWaiting = true + radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) + }, + ) + } + } + + // Inclusive transport-visibility filter chips. Sit between the connection card and the + // device list so users can hide entire transports they're not using. + TransportFilterChips( + showBle = showBleTransport, + showNetwork = showNetworkTransport, + showUsb = showUsbTransport, + onToggleBle = { scanModel.setShowBleTransport(!showBleTransport) }, + onToggleNetwork = { scanModel.setShowNetworkTransport(!showNetworkTransport) }, + onToggleUsb = { scanModel.setShowUsbTransport(!showUsbTransport) }, + ) + }, + second = { + // ── Unified device list ── + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + DeviceList( connectionState = connectionState, selectedDevice = selectedDevice, - persistedDeviceName = persistedDeviceName, bleDevices = bleDevices, + usbDevices = usbDevices, discoveredTcpDevices = discoveredTcpDevices, recentTcpDevices = recentTcpDevices, - usbDevices = usbDevices, - onClickDisconnect = { scanModel.disconnect() }, - ) - - else -> NoDeviceContent() - } - } - - var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) } - LaunchedEffect(selectedDevice) { - DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } - } - - val supportedDeviceTypes = scanModel.supportedDeviceTypes - - // Fallback to a supported type if the current one isn't - LaunchedEffect(supportedDeviceTypes) { - if (selectedDeviceType !in supportedDeviceTypes && supportedDeviceTypes.isNotEmpty()) { - selectedDeviceType = supportedDeviceTypes.first() - } - } - - ConnectionsSegmentedBar( - selectedDeviceType = selectedDeviceType, - supportedDeviceTypes = supportedDeviceTypes, - modifier = Modifier.fillMaxWidth(), - ) { - selectedDeviceType = it - } - - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - when (selectedDeviceType) { - DeviceType.BLE -> { - BLEDevices( - connectionState = connectionState, - selectedDevice = selectedDevice, - scanModel = scanModel, + isBleScanning = isBleScanning, + isNetworkScanning = isNetworkScanning, + showBleSection = showBleTransport, + showNetworkSection = showNetworkTransport, + showUsbSection = showUsbTransport, + onSelectDevice = { scanModel.onSelected(it) }, + onToggleBleScan = { scanModel.toggleBleScan() }, + onToggleNetworkScan = { + if (isNetworkScanning || localNetworkPermissionGranted) { + scanModel.toggleNetworkScan() + } else { + // Prefer requesting the runtime grant over letting the platform fall + // back to the system NSD picker. Persist the user's intent so that if + // they grant after the prompt, the scan starts via the launcher's + // onGranted callback and stays on for next session. + scanModel.persistNetworkAutoScanIntent(true) + requestLocalNetworkPermission() + } + }, + onAddManualAddress = { _, fullAddress -> + val displayAddress = fullAddress.removePrefix(TCP_DEVICE_PREFIX) + scanModel.addRecentAddress(fullAddress, displayAddress) + scanModel.changeDeviceAddress(fullAddress) + }, + onRemoveRecentAddress = { scanModel.removeRecentAddress(it.fullAddress) }, ) } - - DeviceType.TCP -> { - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - NetworkDevices( - connectionState = connectionState, - discoveredNetworkDevices = discoveredTcpDevices, - recentNetworkDevices = recentTcpDevices, - selectedDevice = selectedDevice, - scanModel = scanModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - } - } - - DeviceType.USB -> { - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - UsbDevices( - connectionState = connectionState, - usbDevices = usbDevices, - selectedDevice = selectedDevice, - scanModel = scanModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - } - } - } - } - } - scanStatusText?.let { - Card( - modifier = Modifier.padding(8.dp).align(Alignment.BottomStart), - colors = - CardDefaults.cardColors() - .copy(containerColor = CardDefaults.cardColors().containerColor.copy(alpha = 0.5f)), - ) { - Text( - text = it, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.End, - modifier = Modifier.padding(horizontal = 8.dp), - ) - } + }, + ) } } } } -/** Content shown when connected to a device with node info available. */ +/** Body for the CONNECTED state — sits inside the shared outer Card in [ConnectionsScreen]. */ @Composable private fun ConnectedDeviceContent( ourNode: org.meshtastic.core.model.Node?, - regionUnset: Boolean, selectedDevice: String, bleDevices: List, onNavigateToNodeDetails: (Int) -> Unit, onClickDisconnect: () -> Unit, - onSetRegion: () -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ourNode?.let { node -> - TitledCard(title = stringResource(Res.string.connected_device)) { - CurrentlyConnectedInfo( - node = node, - bleDevice = bleDevices.find { it.fullAddress == selectedDevice } as DeviceListEntry.Ble?, - onNavigateToNodeDetails = onNavigateToNodeDetails, - onClickDisconnect = onClickDisconnect, - ) - } - } - - if (regionUnset && selectedDevice != "m") { - TitledCard(title = null) { - ListItem( - leadingIcon = MeshtasticIcons.Language, - text = stringResource(Res.string.set_your_region), - onClick = onSetRegion, - ) - } - } + ourNode?.let { node -> + CurrentlyConnectedInfo( + node = node, + bleDevice = bleDevices.find { it.fullAddress == selectedDevice } as DeviceListEntry.Ble?, + onNavigateToNodeDetails = onNavigateToNodeDetails, + onClickDisconnect = onClickDisconnect, + ) } } -/** Content shown when connecting or a device is selected but node info is not yet available. */ +/** Body for the CONNECTING state — sits inside the shared outer Card in [ConnectionsScreen]. */ @Composable private fun ConnectingDeviceContent( - connectionState: ConnectionState, selectedDevice: String, persistedDeviceName: String?, bleDevices: List, discoveredTcpDevices: List, recentTcpDevices: List, usbDevices: List, + connectionStatus: ConnectionStatus, + connectionProgress: String?, onClickDisconnect: () -> Unit, ) { val selectedEntry = @@ -352,29 +364,40 @@ private fun ConnectingDeviceContent( val name = selectedEntry?.name ?: persistedDeviceName ?: stringResource(Res.string.unknown_device) val address = selectedEntry?.address ?: selectedDevice - TitledCard(title = stringResource(Res.string.connected_device)) { - ConnectingDeviceInfo( - connectionState = connectionState, - deviceName = name, - deviceAddress = address, - onClickDisconnect = onClickDisconnect, - ) - } + ConnectingDeviceInfo( + deviceName = name, + deviceAddress = address, + connectionStatus = connectionStatus, + connectionProgress = connectionProgress, + onClickDisconnect = onClickDisconnect, + ) } -/** Content shown when no device is selected. */ +/** Body for the NO_DEVICE state — sits inside the shared outer Card in [ConnectionsScreen]. */ @Composable private fun NoDeviceContent() { - Card(modifier = Modifier.fillMaxWidth()) { - EmptyStateContent( + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( imageVector = MeshtasticIcons.NoDevice, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.outlineVariant, + ) + Text( text = stringResource(Res.string.no_device_selected), - modifier = Modifier.height(160.dp), + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.outline, ) } } -/** Visual state for the connection screen's [Crossfade] animation. */ +/** Visual state for the connection screen's [AnimatedContent] transition between the three card body variants. */ private enum class ConnectionUiState { /** No device is selected. */ NO_DEVICE, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt deleted file mode 100644 index 40b3c9abb..000000000 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/BLEDevices.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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.feature.connections.ui.components - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.bluetooth_available_devices -import org.meshtastic.feature.connections.ScannerViewModel - -/** - * Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. - * - * @param connectionState The current connection state of the MeshService. - * @param selectedDevice The full address of the currently selected device. - * @param scanModel The ViewModel responsible for Bluetooth scanning logic. - */ -@Composable -fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanModel: ScannerViewModel) { - val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() - val isScanning by scanModel.isBleScanning.collectAsStateWithLifecycle() - - DisposableEffect(Unit) { - scanModel.startBleScan() - onDispose { scanModel.stopBleScan() } - } - - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(Res.string.bluetooth_available_devices), - modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 16.dp).fillMaxWidth(), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, - ) - - if (isScanning) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) - } - - LazyColumn(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { - items(bleDevices, key = { it.fullAddress }) { device -> - Card( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - shape = MaterialTheme.shapes.large, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), - ) { - DeviceListItem( - connectionState = - connectionState.takeIf { device.fullAddress == selectedDevice } - ?: ConnectionState.Disconnected, - device = device, - onSelect = { scanModel.onSelected(device) }, - rssi = null, - ) - } - } - } - } -} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt index 0d079ebdc..9c6897c53 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt @@ -20,27 +20,23 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected +import org.meshtastic.core.resources.connected_sleeping import org.meshtastic.core.resources.connecting -import org.meshtastic.core.resources.disconnect -import org.meshtastic.core.ui.theme.StatusColors.StatusRed +import org.meshtastic.core.resources.must_set_region +import org.meshtastic.core.resources.not_connected +import org.meshtastic.core.ui.viewmodel.ConnectionStatus /** * Displays the currently connecting (or connected) device with its name, address, connection status, and a disconnect @@ -48,48 +44,41 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed */ @Composable fun ConnectingDeviceInfo( - connectionState: ConnectionState, deviceName: String, deviceAddress: String, + connectionStatus: ConnectionStatus, + connectionProgress: String?, onClickDisconnect: () -> Unit, modifier: Modifier = Modifier, ) { - val statusText = - if (connectionState is ConnectionState.Connected) { - stringResource(Res.string.connected) - } else { - stringResource(Res.string.connecting) + val statusLabel = + when (connectionStatus) { + ConnectionStatus.CONNECTED -> stringResource(Res.string.connected) + ConnectionStatus.MUST_SET_REGION -> stringResource(Res.string.must_set_region) + ConnectionStatus.CONNECTING -> connectionProgress ?: stringResource(Res.string.connecting) + ConnectionStatus.CONNECTED_SLEEPING -> stringResource(Res.string.connected_sleeping) + ConnectionStatus.NOT_CONNECTED -> stringResource(Res.string.not_connected) } - Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) { + + Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - CircularProgressIndicator(modifier = Modifier.size(64.dp)) + CircularProgressIndicator(modifier = Modifier.size(40.dp)) Column { Text(text = deviceName, style = MaterialTheme.typography.headlineSmall) Text(text = deviceAddress, style = MaterialTheme.typography.bodyLarge) Text( - text = statusText, + text = statusLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, ) } } - Button( - shape = RectangleShape, - modifier = Modifier.fillMaxWidth().height(40.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.StatusRed, - contentColor = Color.White, - ), - onClick = onClickDisconnect, - ) { - Text(stringResource(Res.string.disconnect)) - } + DisconnectButton(onClick = onClickDisconnect) } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionActionButton.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionActionButton.kt new file mode 100644 index 000000000..8dd1d4ea4 --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionActionButton.kt @@ -0,0 +1,97 @@ +/* + * 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.feature.connections.ui.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Shared "icon + label" button used throughout the Connections screen. Centralises M3 sizing conventions + * ([ButtonDefaults.IconSize], [ButtonDefaults.IconSpacing], and the variant-appropriate `*WithIconContentPadding`) so + * every scan / add / empty-state affordance renders identically. + * + * @param iconContentDescription accessibility label for the leading icon. Defaults to `null` because the visible [text] + * usually describes the action; override when the icon carries information the text does not. + */ +@Composable +fun ConnectionActionButton( + onClick: () -> Unit, + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, + style: ConnectionActionButtonStyle = ConnectionActionButtonStyle.Filled, + enabled: Boolean = true, + iconContentDescription: String? = null, +) { + val content: @Composable () -> Unit = { + Icon( + imageVector = icon, + contentDescription = iconContentDescription, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text) + } + when (style) { + ConnectionActionButtonStyle.Filled -> + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + content() + } + ConnectionActionButtonStyle.Tonal -> + FilledTonalButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + content() + } + ConnectionActionButtonStyle.Outlined -> + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + content() + } + ConnectionActionButtonStyle.Text -> + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, + ) { + content() + } + } +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionActionButtonStyle.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionActionButtonStyle.kt new file mode 100644 index 000000000..c6bb705db --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionActionButtonStyle.kt @@ -0,0 +1,25 @@ +/* + * 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.feature.connections.ui.components + +/** Visual style for [ConnectionActionButton]. Maps to the four canonical M3 button variants. */ +enum class ConnectionActionButtonStyle { + Filled, + Tonal, + Outlined, + Text, +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt deleted file mode 100644 index af09136f2..000000000 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectionsSegmentedBar.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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.feature.connections.ui.components - -import androidx.compose.material3.Icon -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import org.jetbrains.compose.resources.DrawableResource -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.bluetooth -import org.meshtastic.core.resources.ic_bluetooth -import org.meshtastic.core.resources.ic_usb -import org.meshtastic.core.resources.ic_wifi -import org.meshtastic.core.resources.network -import org.meshtastic.core.resources.serial - -@Suppress("LambdaParameterEventTrailing") -@Composable -fun ConnectionsSegmentedBar( - selectedDeviceType: DeviceType, - supportedDeviceTypes: List, - modifier: Modifier = Modifier, - onClickDeviceType: (DeviceType) -> Unit, -) { - val visibleItems = Item.entries.filter { it.deviceType in supportedDeviceTypes } - if (visibleItems.isEmpty()) return - - SingleChoiceSegmentedButtonRow(modifier = modifier) { - visibleItems.forEachIndexed { index, item -> - val text = stringResource(item.textRes) - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape(index, visibleItems.size), - onClick = { onClickDeviceType(item.deviceType) }, - selected = item.deviceType == selectedDeviceType, - icon = { Icon(imageVector = vectorResource(item.icon), contentDescription = text) }, - label = { Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - ) - } - } -} - -private enum class Item(val icon: DrawableResource, val textRes: StringResource, val deviceType: DeviceType) { - BLUETOOTH(icon = Res.drawable.ic_bluetooth, textRes = Res.string.bluetooth, deviceType = DeviceType.BLE), - NETWORK(icon = Res.drawable.ic_wifi, textRes = Res.string.network, deviceType = DeviceType.TCP), - SERIAL(icon = Res.drawable.ic_usb, textRes = Res.string.serial, deviceType = DeviceType.USB), -} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt index 8f5347e01..a778bd1e5 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt @@ -20,10 +20,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -34,8 +31,6 @@ import androidx.compose.runtime.remember 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.graphics.RectangleShape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger @@ -46,13 +41,11 @@ import kotlinx.coroutines.withTimeout import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.disconnect import org.meshtastic.core.resources.firmware_version import org.meshtastic.core.ui.component.MaterialBatteryInfo import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.component.Rssi import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.Paxcount @@ -62,7 +55,7 @@ import kotlin.time.Duration.Companion.seconds private const val RSSI_DELAY = 2 private const val RSSI_TIMEOUT = 1 -@Suppress("LongMethod", "LoopWithTooManyJumpStatements", "TooGenericExceptionCaught") +@Suppress("LoopWithTooManyJumpStatements", "TooGenericExceptionCaught") @Composable fun CurrentlyConnectedInfo( node: Node, @@ -73,39 +66,33 @@ fun CurrentlyConnectedInfo( ) { var rssi by remember { mutableIntStateOf(0) } LaunchedEffect(bleDevice) { - if (bleDevice != null) { - while (bleDevice.device.isConnected) { - try { - rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() } - } catch (_: TimeoutCancellationException) { - Logger.d { "RSSI read timed out" } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.d(e) { "Failed to read RSSI ${e.message}" } - } - delay(RSSI_DELAY.seconds) + if (bleDevice == null) return@LaunchedEffect + while (bleDevice.device.isConnected) { + try { + rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() } + } catch (_: TimeoutCancellationException) { + Logger.d { "RSSI read timed out" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.d(e) { "Failed to read RSSI ${e.message}" } } + delay(RSSI_DELAY.seconds) } } - Column(modifier = modifier) { + Column(modifier = modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { MaterialBatteryInfo(level = node.batteryLevel, voltage = node.voltage) - if (bleDevice is DeviceListEntry.Ble) { + if (bleDevice != null) { Rssi(rssi = rssi) } } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - NodeChip(node = node, onClick = { onNavigateToNodeDetails(it.num) }) - } + NodeChip(node = node, onClick = { onNavigateToNodeDetails(it.num) }) Column(modifier = Modifier.weight(1f, fill = true)) { Text(text = node.user.long_name, style = MaterialTheme.typography.titleMedium) @@ -124,18 +111,7 @@ fun CurrentlyConnectedInfo( } } - Button( - shape = RectangleShape, - modifier = Modifier.fillMaxWidth().height(40.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.StatusRed, - contentColor = Color.White, - ), - onClick = onClickDisconnect, - ) { - Text(stringResource(Res.string.disconnect)) - } + DisconnectButton(onClick = onClickDisconnect) } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceList.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceList.kt new file mode 100644 index 000000000..119d94cbf --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceList.kt @@ -0,0 +1,486 @@ +/* + * 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.feature.connections.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldLabelPosition +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.isValidAddress +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.network.repository.NetworkConstants +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.add_network_device +import org.meshtastic.core.resources.add_network_device_manually +import org.meshtastic.core.resources.address +import org.meshtastic.core.resources.bluetooth +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.ip_port +import org.meshtastic.core.resources.network +import org.meshtastic.core.resources.no_bluetooth_devices_hint +import org.meshtastic.core.resources.no_bluetooth_devices_seen +import org.meshtastic.core.resources.no_network_devices_hint +import org.meshtastic.core.resources.no_network_devices_seen +import org.meshtastic.core.resources.no_usb_devices_hint +import org.meshtastic.core.resources.no_usb_devices_seen +import org.meshtastic.core.resources.recent_network_devices +import org.meshtastic.core.resources.scan_bluetooth_devices +import org.meshtastic.core.resources.scan_network_devices +import org.meshtastic.core.resources.scanning_bluetooth +import org.meshtastic.core.resources.scanning_network +import org.meshtastic.core.resources.usb +import org.meshtastic.core.ui.icon.Add +import org.meshtastic.core.ui.icon.Bluetooth +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Search +import org.meshtastic.core.ui.icon.Usb +import org.meshtastic.core.ui.icon.Wifi +import org.meshtastic.feature.connections.model.DeviceListEntry + +/** + * Unified device list: BLE / USB / Network sections rendered as one scrollable [LazyColumn]. + * + * Replaces the previous tab-based UI. Every section uses the same M3 header template ([DeviceSectionHeader]); empty + * sections are hidden. Stable per-transport keys (e.g. `"ble:"`) keep LazyColumn's recomposition scope + * tight to the actual item that changed when a user taps a device card. + * + * BLE / network scanning is user-triggered — the header's trailing toggle calls back to the caller. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongParameterList") +@Composable +fun DeviceList( + connectionState: ConnectionState, + selectedDevice: String, + bleDevices: List, + usbDevices: List, + discoveredTcpDevices: List, + recentTcpDevices: List, + isBleScanning: Boolean, + isNetworkScanning: Boolean, + onSelectDevice: (DeviceListEntry) -> Unit, + onToggleBleScan: () -> Unit, + onToggleNetworkScan: () -> Unit, + onAddManualAddress: (address: String, fullAddress: String) -> Unit, + onRemoveRecentAddress: (DeviceListEntry) -> Unit, + modifier: Modifier = Modifier, + showBleSection: Boolean = true, + showNetworkSection: Boolean = true, + showUsbSection: Boolean = true, +) { + var showAddDialog by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + val hideAndDismiss: () -> Unit = { + scope.launch { sheetState.hide() }.invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false } + } + + if (showAddDialog) { + AddDeviceDialog( + sheetState = sheetState, + onHideDialog = hideAndDismiss, + onClickAdd = { address, fullAddress -> + onAddManualAddress(address, fullAddress) + hideAndDismiss() + }, + ) + } + + LazyColumn(modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (showBleSection) { + bluetoothSection( + bleDevices = bleDevices, + connectionState = connectionState, + selectedDevice = selectedDevice, + isBleScanning = isBleScanning, + onSelectDevice = onSelectDevice, + onToggleBleScan = onToggleBleScan, + ) + } + + if (showNetworkSection) { + networkSection( + discoveredTcpDevices = discoveredTcpDevices, + recentTcpDevices = recentTcpDevices, + connectionState = connectionState, + selectedDevice = selectedDevice, + isNetworkScanning = isNetworkScanning, + onSelectDevice = onSelectDevice, + onToggleNetworkScan = onToggleNetworkScan, + onAddManually = { showAddDialog = true }, + onRemoveRecentAddress = onRemoveRecentAddress, + ) + } + + if (showUsbSection) { + usbSection( + usbDevices = usbDevices, + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelectDevice = onSelectDevice, + ) + } + } +} + +@Suppress("LongParameterList") +private fun LazyListScope.bluetoothSection( + bleDevices: List, + connectionState: ConnectionState, + selectedDevice: String, + isBleScanning: Boolean, + onSelectDevice: (DeviceListEntry) -> Unit, + onToggleBleScan: () -> Unit, +) { + item(key = "header:ble", contentType = "header") { + DeviceSectionHeader( + title = stringResource(Res.string.bluetooth), + showProgress = isBleScanning, + trailing = { + ScanToggleAction( + isScanning = isBleScanning, + scanLabel = stringResource(Res.string.scan_bluetooth_devices), + scanningLabel = stringResource(Res.string.scanning_bluetooth), + onToggle = onToggleBleScan, + ) + }, + ) + } + items(bleDevices, key = { device -> "ble:${device.fullAddress}" }, contentType = { "device" }) { device -> + DeviceCard( + device = device, + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelect = onSelectDevice, + modifier = Modifier.animateItem(), + ) + } + if (bleDevices.isEmpty()) { + item(key = "empty:ble", contentType = "empty") { + SectionEmptyState( + text = stringResource(Res.string.no_bluetooth_devices_seen), + supportingText = stringResource(Res.string.no_bluetooth_devices_hint), + imageVector = MeshtasticIcons.Bluetooth, + ) + } + } +} + +private fun LazyListScope.usbSection( + usbDevices: List, + connectionState: ConnectionState, + selectedDevice: String, + onSelectDevice: (DeviceListEntry) -> Unit, +) { + item(key = "header:usb", contentType = "header") { DeviceSectionHeader(title = stringResource(Res.string.usb)) } + items(usbDevices, key = { device -> "usb:${device.fullAddress}" }, contentType = { "device" }) { device -> + DeviceCard( + device = device, + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelect = onSelectDevice, + ) + } + if (usbDevices.isEmpty()) { + item(key = "empty:usb", contentType = "empty") { + SectionEmptyState( + text = stringResource(Res.string.no_usb_devices_seen), + supportingText = stringResource(Res.string.no_usb_devices_hint), + imageVector = MeshtasticIcons.Usb, + ) + } + } +} + +@Suppress("LongParameterList") +private fun LazyListScope.networkSection( + discoveredTcpDevices: List, + recentTcpDevices: List, + connectionState: ConnectionState, + selectedDevice: String, + isNetworkScanning: Boolean, + onSelectDevice: (DeviceListEntry) -> Unit, + onToggleNetworkScan: () -> Unit, + onAddManually: () -> Unit, + onRemoveRecentAddress: (DeviceListEntry) -> Unit, +) { + item(key = "header:tcp-discovered", contentType = "header") { + DeviceSectionHeader( + title = stringResource(Res.string.network), + showProgress = isNetworkScanning, + trailing = { + ScanToggleAction( + isScanning = isNetworkScanning, + scanLabel = stringResource(Res.string.scan_network_devices), + scanningLabel = stringResource(Res.string.scanning_network), + onToggle = onToggleNetworkScan, + ) + }, + ) + } + items( + discoveredTcpDevices, + key = { device -> "tcp-discovered:${device.fullAddress}" }, + contentType = { "device" }, + ) { device -> + DeviceCard( + device = device, + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelect = onSelectDevice, + ) + } + + if (discoveredTcpDevices.isEmpty() && recentTcpDevices.isEmpty()) { + item(key = "empty:tcp", contentType = "empty") { + SectionEmptyState( + text = stringResource(Res.string.no_network_devices_seen), + supportingText = stringResource(Res.string.no_network_devices_hint), + imageVector = MeshtasticIcons.Wifi, + ) + } + } + + recentNetworkSection( + recentTcpDevices = recentTcpDevices, + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelectDevice = onSelectDevice, + onRemoveRecentAddress = onRemoveRecentAddress, + ) + + item(key = "action:add-network", contentType = "action") { + ConnectionActionButton( + onClick = onAddManually, + icon = MeshtasticIcons.Add, + text = stringResource(Res.string.add_network_device_manually), + modifier = Modifier.fillMaxWidth(), + style = ConnectionActionButtonStyle.Tonal, + ) + } + + item(key = "spacer:bottom", contentType = "spacer") { Spacer(Modifier.height(16.dp)) } +} + +private fun LazyListScope.recentNetworkSection( + recentTcpDevices: List, + connectionState: ConnectionState, + selectedDevice: String, + onSelectDevice: (DeviceListEntry) -> Unit, + onRemoveRecentAddress: (DeviceListEntry) -> Unit, +) { + if (recentTcpDevices.isEmpty()) return + item(key = "header:tcp-recent", contentType = "header") { + DeviceSectionHeader(title = stringResource(Res.string.recent_network_devices)) + } + items( + recentTcpDevices, + key = { device -> "tcp-recent:${device.fullAddress}" }, + contentType = { "device" }, + ) { device -> + DeviceCard( + device = device, + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelect = onSelectDevice, + onDelete = onRemoveRecentAddress, + ) + } +} + +/** Single device row: card + [DeviceListItem]. Factored out so every section renders items identically. */ +@Composable +private fun DeviceCard( + device: DeviceListEntry, + connectionState: ConnectionState, + selectedDevice: String, + onSelect: (DeviceListEntry) -> Unit, + modifier: Modifier = Modifier, + onDelete: ((DeviceListEntry) -> Unit)? = null, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + DeviceListItem( + connectionState = + connectionState.takeIf { device.fullAddress == selectedDevice } ?: ConnectionState.Disconnected, + device = device, + onSelect = { onSelect(device) }, + onDelete = onDelete?.let { delete -> { delete(device) } }, + rssi = (device as? DeviceListEntry.Ble)?.device?.rssi, + ) + } +} + +/** Compact text-button variant of the scan toggle, used inside a section header's trailing slot. */ +@Composable +private fun ScanToggleAction(isScanning: Boolean, scanLabel: String, scanningLabel: String, onToggle: () -> Unit) { + ConnectionActionButton( + onClick = onToggle, + icon = if (isScanning) MeshtasticIcons.Close else MeshtasticIcons.Search, + text = if (isScanning) scanningLabel else scanLabel, + style = ConnectionActionButtonStyle.Text, + ) +} + +/** + * Inline empty state for an individual transport section. Follows Material 3 inline empty-state guidance: a small, + * muted icon, a short title, and an optional supporting hint. Rendered within the section's flow (no full-page + * takeover); encourages the user to act via the section header's scan toggle rather than duplicating action buttons. + */ +@Composable +private fun SectionEmptyState( + text: String, + imageVector: ImageVector, + modifier: Modifier = Modifier, + supportingText: String? = null, +) { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (supportingText != null) { + Text( + text = supportingText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +/** Dialog for manually adding a TCP device by IP address and port. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddDeviceDialog( + sheetState: SheetState, + onHideDialog: () -> Unit, + onClickAdd: (address: String, fullAddress: String) -> Unit, +) { + val addressState = rememberTextFieldState("") + val portState = rememberTextFieldState(NetworkConstants.SERVICE_PORT.toString()) + + @Suppress("MagicNumber") + ModalBottomSheet(onDismissRequest = onHideDialog, sheetState = sheetState) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + state = addressState, + labelPosition = TextFieldLabelPosition.Above(), + lineLimits = TextFieldLineLimits.SingleLine, + label = { Text(stringResource(Res.string.address)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + modifier = Modifier.weight(.7f), + ) + + OutlinedTextField( + state = portState, + labelPosition = TextFieldLabelPosition.Above(), + placeholder = { Text(NetworkConstants.SERVICE_PORT.toString()) }, + lineLimits = TextFieldLineLimits.SingleLine, + label = { Text(stringResource(Res.string.ip_port)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done), + modifier = Modifier.weight(.3f), + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(modifier = Modifier.weight(1f), onClick = { onHideDialog() }) { + Text(stringResource(Res.string.cancel)) + } + + Button( + modifier = Modifier.weight(1f), + onClick = { + val address = addressState.text.toString() + if (address.isValidAddress()) { + val portString = portState.text.toString() + val port = portString.toIntOrNull() + + val combinedString = + if (port != null && port != NetworkConstants.SERVICE_PORT) { + "$address:$portString" + } else { + address + } + + onClickAdd(combinedString, "t$combinedString") + } + }, + ) { + Text(stringResource(Res.string.add_network_device)) + } + } + } + } +} 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 14f4dc42b..e7708b685 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 @@ -44,6 +44,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource @@ -127,25 +128,22 @@ fun DeviceListItem( Modifier.selectable(selected = isSelected, role = Role.RadioButton, onClick = onSelect) } + val iconTint = + if (connectionState is ConnectionState.Connected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ListItem( modifier = modifier.fillMaxWidth().then(clickableModifier).padding(vertical = 4.dp), - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - device.node?.let { node -> NodeChip(node = node) } - ?: Text(text = device.name, style = MaterialTheme.typography.titleLarge) - } - }, + headlineContent = { DeviceHeadline(device = device) }, leadingContent = { Icon( imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(32.dp), - tint = - if (connectionState is ConnectionState.Connected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + tint = iconTint, ) }, supportingContent = { Text(text = device.address, style = MaterialTheme.typography.bodyLarge) }, @@ -165,3 +163,23 @@ fun DeviceListItem( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } + +/** + * Headline for a device row. When we have a [DeviceListEntry.node] in the local DB (i.e. we've previously connected and + * learned the device's mesh identity), render the colored [NodeChip] + the node's long name so users can visually + * identify the device at a glance. Otherwise fall back to the raw advertised device name. + */ +@Composable +private fun DeviceHeadline(device: DeviceListEntry) { + val node = device.node + if (node != null) { + NodeChip(node = node) + } else { + Text( + text = device.name, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListSection.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListSection.kt deleted file mode 100644 index 519a27531..000000000 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListSection.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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.feature.connections.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.feature.connections.model.DeviceListEntry - -@Composable -fun List.DeviceListSection( - title: String, - connectionState: ConnectionState, - selectedDevice: String, - onSelect: (DeviceListEntry) -> Unit, - modifier: Modifier = Modifier, - onDelete: ((DeviceListEntry) -> Unit)? = null, -) { - if (isNotEmpty()) { - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = title, - modifier = Modifier.padding(horizontal = 8.dp).fillMaxWidth(), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, - ) - - this@DeviceListSection.forEach { device -> - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), - ) { - DeviceListItem( - connectionState = - connectionState.takeIf { device.fullAddress == selectedDevice } - ?: ConnectionState.Disconnected, - device = device, - onSelect = { onSelect(device) }, - onDelete = onDelete?.let { delete -> { delete(device) } }, - modifier = Modifier, - ) - } - } - } - } -} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceSectionHeader.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceSectionHeader.kt new file mode 100644 index 000000000..8374afca6 --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceSectionHeader.kt @@ -0,0 +1,66 @@ +/* + * 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.feature.connections.ui.components + +import androidx.compose.foundation.layout.Arrangement +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.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Section header used to group content in the Connections list. + * + * Follows the Material 3 "header + trailing action" pattern: a [titleSmall] label on the left, an optional composable + * [trailing] slot on the right (typically a [androidx.compose.material3.TextButton] for a scan toggle), and an optional + * thin [LinearProgressIndicator] rendered underneath when [showProgress] is true. + * + * The header title and the trailing action are rendered on the same baseline so a section's control never drifts above + * or below its header. + */ +@Composable +fun DeviceSectionHeader( + title: String, + modifier: Modifier = Modifier, + showProgress: Boolean = false, + trailing: @Composable () -> Unit = {}, +) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + trailing() + } + if (showProgress) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DisconnectButton.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DisconnectButton.kt new file mode 100644 index 000000000..4ae5e890f --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DisconnectButton.kt @@ -0,0 +1,44 @@ +/* + * 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.feature.connections.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.disconnect + +/** + * Full-width error-tinted [OutlinedButton] used by both the "currently connected" and "connecting" card states. + * Centralises the color/label so every call site stays in lock-step. + */ +@Composable +fun DisconnectButton(onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { + OutlinedButton( + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + enabled = enabled, + onClick = onClick, + ) { + Text(stringResource(Res.string.disconnect)) + } +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/EmptyStateContent.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/EmptyStateContent.kt index cdf67bad2..81412bb29 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/EmptyStateContent.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/EmptyStateContent.kt @@ -47,14 +47,14 @@ fun EmptyStateContent( imageVector = imageVector, contentDescription = null, modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + tint = MaterialTheme.colorScheme.outlineVariant, ) Text( text = text, modifier = Modifier.padding(top = 16.dp), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + color = MaterialTheme.colorScheme.outline, ) if (action != null) { Column(modifier = Modifier.padding(top = 24.dp)) { action() } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt deleted file mode 100644 index 3ff51db1e..000000000 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/NetworkDevices.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * 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.feature.connections.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SheetState -import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldLabelPosition -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.isValidAddress -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.network.repository.NetworkConstants -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.add_network_device -import org.meshtastic.core.resources.address -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.discovered_network_devices -import org.meshtastic.core.resources.ip_port -import org.meshtastic.core.resources.no_network_devices_found -import org.meshtastic.core.resources.recent_network_devices -import org.meshtastic.core.ui.icon.Add -import org.meshtastic.core.ui.icon.HardwareModel -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.feature.connections.ScannerViewModel -import org.meshtastic.feature.connections.model.DeviceListEntry - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NetworkDevices( - connectionState: ConnectionState, - discoveredNetworkDevices: List, - recentNetworkDevices: List, - selectedDevice: String, - scanModel: ScannerViewModel, -) { - var showAddDialog by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - - if (showAddDialog) { - AddDeviceDialog( - sheetState = sheetState, - onHideDialog = { - scope - .launch { sheetState.hide() } - .invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false } - }, - onClickAdd = { address, fullAddress -> - scanModel.addRecentAddress(fullAddress, address) - scanModel.changeDeviceAddress(fullAddress) - scope - .launch { sheetState.hide() } - .invokeOnCompletion { if (!sheetState.isVisible) showAddDialog = false } - }, - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (discoveredNetworkDevices.isEmpty() && recentNetworkDevices.isEmpty()) { - EmptyStateContent( - text = stringResource(Res.string.no_network_devices_found), - imageVector = MeshtasticIcons.HardwareModel, - modifier = Modifier.padding(vertical = 32.dp), - ) { - Button(onClick = { showAddDialog = true }) { - Icon(MeshtasticIcons.Add, contentDescription = null) - Text(stringResource(Res.string.add_network_device)) - } - } - } else { - if (discoveredNetworkDevices.isNotEmpty()) { - discoveredNetworkDevices.DeviceListSection( - title = stringResource(Res.string.discovered_network_devices), - connectionState = connectionState, - selectedDevice = selectedDevice, - onSelect = { scanModel.onSelected(it) }, - ) - } - - if (recentNetworkDevices.isNotEmpty()) { - recentNetworkDevices.DeviceListSection( - title = stringResource(Res.string.recent_network_devices), - connectionState = connectionState, - selectedDevice = selectedDevice, - onSelect = { scanModel.onSelected(it) }, - onDelete = { scanModel.removeRecentAddress(it.fullAddress) }, - ) - } - - Row(modifier = Modifier.padding(top = 8.dp)) { - FloatingActionButton(onClick = { showAddDialog = true }) { - Icon(MeshtasticIcons.Add, contentDescription = stringResource(Res.string.add_network_device)) - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AddDeviceDialog( - sheetState: SheetState, - onHideDialog: () -> Unit, - onClickAdd: (address: String, fullAddress: String) -> Unit, -) { - val addressState = rememberTextFieldState("") - val portState = rememberTextFieldState(NetworkConstants.SERVICE_PORT.toString()) - - @Suppress("MagicNumber") - ModalBottomSheet(onDismissRequest = onHideDialog, sheetState = sheetState) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - state = addressState, - labelPosition = TextFieldLabelPosition.Above(), - lineLimits = TextFieldLineLimits.SingleLine, - label = { Text(stringResource(Res.string.address)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), - modifier = Modifier.weight(.7f), - ) - - OutlinedTextField( - state = portState, - labelPosition = TextFieldLabelPosition.Above(), - placeholder = { Text(NetworkConstants.SERVICE_PORT.toString()) }, - lineLimits = TextFieldLineLimits.SingleLine, - label = { Text(stringResource(Res.string.ip_port)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done), - modifier = Modifier.weight(.3f), - ) - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(modifier = Modifier.weight(1f), onClick = { onHideDialog() }) { - Text(stringResource(Res.string.cancel)) - } - - Button( - modifier = Modifier.weight(1f), - onClick = { - val address = addressState.text.toString() - if (address.isValidAddress()) { - val portString = portState.text.toString() - val port = portString.toIntOrNull() - - val combinedString = - if (port != null && port != NetworkConstants.SERVICE_PORT) { - "$address:$portString" - } else { - address - } - - onClickAdd(combinedString, "t$combinedString") - } - }, - ) { - Text(stringResource(Res.string.add_network_device)) - } - } - } - } -} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/TransportFilterChips.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/TransportFilterChips.kt new file mode 100644 index 000000000..32f4720c3 --- /dev/null +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/TransportFilterChips.kt @@ -0,0 +1,89 @@ +/* + * 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.feature.connections.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.transport_ble +import org.meshtastic.core.resources.transport_tcp +import org.meshtastic.core.resources.transport_usb +import org.meshtastic.core.ui.icon.Bluetooth +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Usb +import org.meshtastic.core.ui.icon.Wifi + +/** + * Inclusive transport-visibility filter chips rendered below the connection card. Each chip independently toggles the + * visibility of its corresponding section ([showBle] → BLE, [showNetwork] → Network/TCP, [showUsb] → USB) in the device + * list. Selections are persisted by the caller (defaults to all-on). + */ +@Composable +fun TransportFilterChips( + showBle: Boolean, + showNetwork: Boolean, + showUsb: Boolean, + onToggleBle: () -> Unit, + onToggleNetwork: () -> Unit, + onToggleUsb: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + TransportChip( + selected = showBle, + label = Res.string.transport_ble, + icon = MeshtasticIcons.Bluetooth, + onClick = onToggleBle, + ) + TransportChip( + selected = showNetwork, + label = Res.string.transport_tcp, + icon = MeshtasticIcons.Wifi, + onClick = onToggleNetwork, + ) + TransportChip( + selected = showUsb, + label = Res.string.transport_usb, + icon = MeshtasticIcons.Usb, + onClick = onToggleUsb, + ) + } +} + +@Composable +private fun TransportChip(selected: Boolean, label: StringResource, icon: ImageVector, onClick: () -> Unit) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(stringResource(label)) }, + leadingIcon = { Icon(imageVector = icon, contentDescription = null) }, + ) +} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt deleted file mode 100644 index ef1183c3f..000000000 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/UsbDevices.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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.feature.connections.ui.components - -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.no_usb_devices_found -import org.meshtastic.core.resources.usb -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.UsbOff -import org.meshtastic.feature.connections.ScannerViewModel -import org.meshtastic.feature.connections.model.DeviceListEntry - -@Composable -fun UsbDevices( - connectionState: ConnectionState, - usbDevices: List, - selectedDevice: String, - scanModel: ScannerViewModel, -) { - if (usbDevices.isEmpty()) { - EmptyStateContent( - text = stringResource(Res.string.no_usb_devices_found), - imageVector = MeshtasticIcons.UsbOff, - modifier = Modifier.padding(vertical = 32.dp), - ) - } else { - usbDevices.DeviceListSection( - title = stringResource(Res.string.usb), - connectionState = connectionState, - selectedDevice = selectedDevice, - onSelect = scanModel::onSelected, - ) - } -} diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt index 04e9ac03e..d816be49e 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt @@ -22,14 +22,20 @@ import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.meshtastic.core.datastore.RecentAddressesDataSource +import org.meshtastic.core.network.repository.DiscoveredService +import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.DiscoveredDevices import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase import kotlin.test.BeforeTest @@ -46,22 +52,41 @@ class ScannerViewModelTest { private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) private val radioPrefs: RadioPrefs = mock(MockMode.autofill) private val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill) - private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase = mock(MockMode.autofill) + private val networkRepository: NetworkRepository = mock(MockMode.autofill) private val bleScanner: org.meshtastic.core.ble.BleScanner = mock(MockMode.autofill) + private val uiPrefs = org.meshtastic.core.testing.FakeUiPrefs() - private val discoveredDevicesFlow = MutableStateFlow(DiscoveredDevices()) + private val resolvedServicesFlow = MutableStateFlow>(emptyList()) + private val baseDevicesFlow = MutableStateFlow(DiscoveredDevices()) + + /** + * A fake [GetDiscoveredDevicesUseCase] that mirrors the real behavior: it combines the provided [resolvedList] with + * base device data so tests can verify NSD gating. + */ + private val getDiscoveredDevicesUseCase = + object : GetDiscoveredDevicesUseCase { + override fun invoke( + showMock: Boolean, + resolvedList: Flow>, + ): Flow = combine(baseDevicesFlow, resolvedList) { base, resolved -> + val tcpDevices = + resolved.map { DeviceListEntry.Tcp(name = it.name, fullAddress = "t${it.hostAddress}") } + base.copy(discoveredTcpDevices = tcpDevices) + } + } @BeforeTest fun setUp() { every { radioInterfaceService.isMockTransport() } returns false every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null) - every { radioInterfaceService.supportedDeviceTypes } returns emptyList() - every { getDiscoveredDevicesUseCase.invoke(any()) } returns discoveredDevicesFlow every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList()) + every { networkRepository.resolvedList } returns resolvedServicesFlow + every { networkRepository.networkAvailable } returns flowOf(true) serviceRepository.setConnectionProgress("") - discoveredDevicesFlow.value = DiscoveredDevices() + baseDevicesFlow.value = DiscoveredDevices() + resolvedServicesFlow.value = emptyList() viewModel = ScannerViewModel( @@ -71,12 +96,14 @@ class ScannerViewModelTest { radioPrefs = radioPrefs, recentAddressesDataSource = recentAddressesDataSource, getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase, + networkRepository = networkRepository, dispatchers = org.meshtastic.core.di.CoroutineDispatchers( io = UnconfinedTestDispatcher(), main = UnconfinedTestDispatcher(), default = UnconfinedTestDispatcher(), ), + uiPrefs = uiPrefs, bleScanner = bleScanner, ) } @@ -124,16 +151,61 @@ class ScannerViewModelTest { assertEquals(emptyList(), awaitItem()) val device = - org.meshtastic.feature.connections.model.DeviceListEntry.Usb( + DeviceListEntry.Usb( usbData = object : org.meshtastic.feature.connections.model.UsbDeviceData {}, name = "USB Device", fullAddress = "usb_address", bonded = true, ) - discoveredDevicesFlow.value = DiscoveredDevices(usbDevices = listOf(device)) + baseDevicesFlow.value = DiscoveredDevices(usbDevices = listOf(device)) assertEquals(listOf(device), awaitItem()) cancelAndIgnoreRemainingEvents() } } + + @Test + fun `isNetworkScanning defaults to false`() { + assertEquals(false, viewModel.isNetworkScanning.value) + } + + @Test + fun `startNetworkScan updates isNetworkScanning`() = runTest { + viewModel.isNetworkScanning.test { + assertEquals(false, awaitItem()) + viewModel.startNetworkScan() + assertEquals(true, awaitItem()) + viewModel.stopNetworkScan() + assertEquals(false, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `discoveredTcpDevicesForUi is empty when not scanning`() = runTest { + resolvedServicesFlow.value = + listOf(DiscoveredService(name = "NSD Device", hostAddress = "192.168.1.50", port = 4403)) + + viewModel.discoveredTcpDevicesForUi.test { + assertEquals(emptyList(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `discoveredTcpDevicesForUi populates when scanning is active`() = runTest { + resolvedServicesFlow.value = + listOf(DiscoveredService(name = "NSD Device", hostAddress = "192.168.1.50", port = 4403)) + + viewModel.discoveredTcpDevicesForUi.test { + assertEquals(emptyList(), awaitItem()) + viewModel.startNetworkScan() + val result = awaitItem() + assertEquals(1, result.size) + assertEquals("t192.168.1.50", result[0].fullAddress) + viewModel.stopNetworkScan() + assertEquals(emptyList(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt index c1ac1e70c..1f8bde5ab 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCaseTest.kt @@ -29,7 +29,6 @@ import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.network.repository.DiscoveredService -import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.TestDataFactory import kotlin.test.Test @@ -43,7 +42,6 @@ class CommonGetDiscoveredDevicesUseCaseTest { private lateinit var nodeRepository: FakeNodeRepository private lateinit var recentAddressesDataSource: RecentAddressesDataSource private lateinit var databaseManager: DatabaseManager - private lateinit var networkRepository: NetworkRepository private val recentAddressesFlow = MutableStateFlow>(emptyList()) private val resolvedServicesFlow = MutableStateFlow>(emptyList()) @@ -51,24 +49,19 @@ class CommonGetDiscoveredDevicesUseCaseTest { nodeRepository = FakeNodeRepository() recentAddressesDataSource = mock { every { recentAddresses } returns recentAddressesFlow } databaseManager = mock { every { hasDatabaseFor(any()) } returns false } - networkRepository = mock { - every { resolvedList } returns resolvedServicesFlow - every { networkAvailable } returns flowOf(true) - } useCase = CommonGetDiscoveredDevicesUseCase( recentAddressesDataSource = recentAddressesDataSource, nodeRepository = nodeRepository, databaseManager = databaseManager, - networkRepository = networkRepository, ) } @Test fun testEmptyRecentAddresses() = runTest { setUp() - useCase.invoke(showMock = false).test { + useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test { val result = awaitItem() assertTrue(result.recentTcpDevices.isEmpty(), "No recent TCP devices when empty") assertTrue(result.usbDevices.isEmpty(), "No USB devices when showMock=false") @@ -83,7 +76,7 @@ class CommonGetDiscoveredDevicesUseCaseTest { recentAddressesFlow.value = listOf(RecentAddress("t192.168.1.100", "Zebra_Node"), RecentAddress("t192.168.1.101", "Alpha_Node")) - useCase.invoke(showMock = false).test { + useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test { val result = awaitItem() result.recentTcpDevices.size shouldBe 2 result.recentTcpDevices[0].name shouldBe "Alpha_Node" @@ -95,7 +88,7 @@ class CommonGetDiscoveredDevicesUseCaseTest { @Test fun testShowMockAddsDemo() = runTest { setUp() - useCase.invoke(showMock = true).test { + useCase.invoke(showMock = true, resolvedList = resolvedServicesFlow).test { val result = awaitItem() result.usbDevices.size shouldBe 1 cancelAndIgnoreRemainingEvents() @@ -105,7 +98,7 @@ class CommonGetDiscoveredDevicesUseCaseTest { @Test fun testHideMockNoDemo() = runTest { setUp() - useCase.invoke(showMock = false).test { + useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test { val result = awaitItem() assertTrue(result.usbDevices.isEmpty(), "No mock device when showMock=false") cancelAndIgnoreRemainingEvents() @@ -124,12 +117,11 @@ class CommonGetDiscoveredDevicesUseCaseTest { recentAddressesDataSource = recentAddressesDataSource, nodeRepository = nodeRepository, databaseManager = databaseManager, - networkRepository = networkRepository, ) recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234")) - useCase.invoke(showMock = false).test { + useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test { val result = awaitItem() result.recentTcpDevices.size shouldBe 1 assertNotNull(result.recentTcpDevices[0].node, "Node should be matched by suffix") @@ -146,7 +138,7 @@ class CommonGetDiscoveredDevicesUseCaseTest { recentAddressesFlow.value = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234")) - useCase.invoke(showMock = false).test { + useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test { val result = awaitItem() result.recentTcpDevices.size shouldBe 1 assertNull(result.recentTcpDevices[0].node, "Node should not be matched when no database") @@ -159,7 +151,7 @@ class CommonGetDiscoveredDevicesUseCaseTest { setUp() recentAddressesFlow.value = listOf(RecentAddress("t192.168.1.100", "Node_A")) - useCase.invoke(showMock = false).test { + useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test { val firstResult = awaitItem() firstResult.recentTcpDevices.size shouldBe 1 @@ -184,7 +176,7 @@ class CommonGetDiscoveredDevicesUseCaseTest { ), ) - useCase.invoke(showMock = false).test { + useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test { val result = awaitItem() result.discoveredTcpDevices.size shouldBe 1 result.discoveredTcpDevices[0].name shouldBe "Mesh_1234" @@ -206,7 +198,6 @@ class CommonGetDiscoveredDevicesUseCaseTest { recentAddressesDataSource = recentAddressesDataSource, nodeRepository = nodeRepository, databaseManager = databaseManager, - networkRepository = networkRepository, ) resolvedServicesFlow.value = @@ -219,7 +210,7 @@ class CommonGetDiscoveredDevicesUseCaseTest { ), ) - useCase.invoke(showMock = false).test { + useCase.invoke(showMock = false, resolvedList = resolvedServicesFlow).test { val result = awaitItem() result.discoveredTcpDevices.size shouldBe 1 assertNotNull(result.discoveredTcpDevices[0].node) @@ -228,4 +219,28 @@ class CommonGetDiscoveredDevicesUseCaseTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun testEmptyResolvedListReturnsNoDiscoveredDevices() = runTest { + setUp() + recentAddressesFlow.value = listOf(RecentAddress("t192.168.1.100", "Recent_Node")) + + useCase.invoke(showMock = false, resolvedList = flowOf(emptyList())).test { + val result = awaitItem() + assertTrue(result.discoveredTcpDevices.isEmpty(), "No NSD devices when resolvedList is empty") + result.recentTcpDevices.size shouldBe 1 + result.recentTcpDevices[0].name shouldBe "Recent_Node" + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun testEmptyResolvedListIncludesMock() = runTest { + setUp() + useCase.invoke(showMock = true, resolvedList = flowOf(emptyList())).test { + val result = awaitItem() + result.usbDevices.size shouldBe 1 + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt index 1c1597466..73a10a8bb 100644 --- a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt +++ b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt @@ -19,9 +19,11 @@ package org.meshtastic.feature.connections import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.model.RadioController +import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase /** @@ -39,7 +41,9 @@ class JvmScannerViewModel( radioPrefs: RadioPrefs, recentAddressesDataSource: RecentAddressesDataSource, getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase, + networkRepository: NetworkRepository, dispatchers: org.meshtastic.core.di.CoroutineDispatchers, + uiPrefs: UiPrefs, bleScanner: org.meshtastic.core.ble.BleScanner? = null, ) : ScannerViewModel( serviceRepository, @@ -48,6 +52,8 @@ class JvmScannerViewModel( radioPrefs, recentAddressesDataSource, getDiscoveredDevicesUseCase, + networkRepository, dispatchers, + uiPrefs, bleScanner, ) diff --git a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmGetDiscoveredDevicesUseCase.kt b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmGetDiscoveredDevicesUseCase.kt new file mode 100644 index 000000000..491c984f4 --- /dev/null +++ b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmGetDiscoveredDevicesUseCase.kt @@ -0,0 +1,42 @@ +/* + * 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.feature.connections.domain.usecase + +import org.koin.core.annotation.Single +import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.datastore.RecentAddressesDataSource +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase + +/** + * JVM/Desktop binding for [org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase]. + * + * The common use-case body lives in [CommonGetDiscoveredDevicesUseCase] (un-annotated, so it does not collide with the + * Android impl). This thin subclass registers it with Koin only for JVM/Desktop targets, where [JvmUsbScanner] supplies + * the USB data source. + * + * The explicit `binds` is required because Koin annotations only infer interface bindings from directly-implemented + * interfaces — the [GetDiscoveredDevicesUseCase] interface is implemented on the parent + * [CommonGetDiscoveredDevicesUseCase], which the annotation processor does not walk. + */ +@Single(binds = [GetDiscoveredDevicesUseCase::class]) +class JvmGetDiscoveredDevicesUseCase( + recentAddressesDataSource: RecentAddressesDataSource, + nodeRepository: NodeRepository, + databaseManager: DatabaseManager, + usbScanner: UsbScanner? = null, +) : CommonGetDiscoveredDevicesUseCase(recentAddressesDataSource, nodeRepository, databaseManager, usbScanner) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 2e8093ad8..bc1d6b0f0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -21,18 +21,24 @@ package org.meshtastic.feature.node.list import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.material3.animateFloatingActionButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -42,8 +48,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.Flow @@ -55,10 +64,18 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_invalid import org.meshtastic.core.resources.node_count_template import org.meshtastic.core.resources.nodes +import org.meshtastic.core.resources.nodes_empty_disconnected_hint +import org.meshtastic.core.resources.nodes_empty_disconnected_title +import org.meshtastic.core.resources.nodes_empty_searching_hint +import org.meshtastic.core.resources.nodes_empty_searching_title +import org.meshtastic.core.resources.set_up_connection import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticImportFAB import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.smartScrollToTop +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.NoDevice +import org.meshtastic.core.ui.icon.Nodes import org.meshtastic.feature.node.component.NodeContextMenu import org.meshtastic.feature.node.component.NodeFilterTextField import org.meshtastic.feature.node.component.NodeItem @@ -73,6 +90,7 @@ fun NodeListScreen( scrollToTopEvents: Flow? = null, activeNodeId: Int? = null, onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onNavigateToConnections: () -> Unit = {}, ) { val showToast = org.meshtastic.core.ui.util.rememberShowToastResource() val scope = rememberCoroutineScope() @@ -205,8 +223,75 @@ fun NodeListScreen( } } } + if (nodes.isEmpty() && !state.filter.isActive) { + item { + NodeListEmptyState( + connectionState = connectionState, + onNavigateToConnections = onNavigateToConnections, + modifier = Modifier.fillParentMaxSize(), + ) + } + } item { Spacer(modifier = Modifier.height(88.dp)) } } } } } + +/** + * Inline empty state for the Nodes screen. Material 3 inline empty-state guidance: a small muted icon, short title, and + * supporting hint. When the user has no device selected (or is otherwise disconnected), an action button routes them to + * the Connections tab; when connected with no nodes yet we show a passive "searching" state. + */ +@Composable +private fun NodeListEmptyState( + connectionState: ConnectionState, + onNavigateToConnections: () -> Unit, + modifier: Modifier = Modifier, +) { + val isConnected = connectionState == ConnectionState.Connected + val (icon: ImageVector, title: String, hint: String) = + if (isConnected) { + Triple( + MeshtasticIcons.Nodes, + stringResource(Res.string.nodes_empty_searching_title), + stringResource(Res.string.nodes_empty_searching_hint), + ) + } else { + Triple( + MeshtasticIcons.NoDevice, + stringResource(Res.string.nodes_empty_disconnected_title), + stringResource(Res.string.nodes_empty_disconnected_hint), + ) + } + Column( + modifier = modifier.padding(horizontal = 32.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = hint, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (!isConnected) { + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onNavigateToConnections) { Text(stringResource(Res.string.set_up_connection)) } + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index 172a296eb..1950112f5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -169,7 +169,11 @@ data class NodeFilterState( val onlyDirect: Boolean = false, val showIgnored: Boolean = false, val excludeMqtt: Boolean = false, -) +) { + /** True if any user-applied filter is narrowing the visible node set. */ + val isActive: Boolean + get() = filterText.isNotEmpty() || excludeInfrastructure || onlyOnline || onlyDirect || excludeMqtt +} data class NodeFilterToggles( val includeUnknown: Boolean = false, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index dc72fac5e..9b89e6e6c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -32,6 +32,7 @@ fun AdaptiveNodeListScreen( backStack: NavBackStack, scrollToTopEvents: Flow, onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onNavigateToConnections: () -> Unit = {}, ) { val nodeListViewModel: NodeListViewModel = koinViewModel() @@ -42,5 +43,6 @@ fun AdaptiveNodeListScreen( scrollToTopEvents = scrollToTopEvents, activeNodeId = null, onHandleDeepLink = onHandleDeepLink, + onNavigateToConnections = onNavigateToConnections, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 233942f00..0df0a03ab 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -74,12 +74,14 @@ fun EntryProviderScope.nodesGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onNavigateToConnections: () -> Unit = {}, ) { entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, onHandleDeepLink = onHandleDeepLink, + onNavigateToConnections = onNavigateToConnections, ) } @@ -88,10 +90,11 @@ fun EntryProviderScope.nodesGraph( backStack = backStack, scrollToTopEvents = scrollToTopEvents, onHandleDeepLink = onHandleDeepLink, + onNavigateToConnections = onNavigateToConnections, ) } - nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink) + nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink, onNavigateToConnections) } @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -100,12 +103,14 @@ fun EntryProviderScope.nodeDetailGraph( backStack: NavBackStack, scrollToTopEvents: Flow, onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onNavigateToConnections: () -> Unit = {}, ) { entry(metadata = { ListDetailSceneStrategy.listPane() }) { args -> AdaptiveNodeListScreen( backStack = backStack, scrollToTopEvents = scrollToTopEvents, onHandleDeepLink = onHandleDeepLink, + onNavigateToConnections = onNavigateToConnections, ) } From ab9c517c0ac95e464a0365e373a9675525c291e4 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:25:05 -0500 Subject: [PATCH 46/65] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5220) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../src/commonMain/composeResources/values-be/strings.xml | 1 + .../src/commonMain/composeResources/values-bg/strings.xml | 2 ++ .../src/commonMain/composeResources/values-cs/strings.xml | 1 + .../src/commonMain/composeResources/values-de/strings.xml | 3 +++ .../src/commonMain/composeResources/values-es/strings.xml | 2 ++ .../src/commonMain/composeResources/values-et/strings.xml | 1 + .../src/commonMain/composeResources/values-fi/strings.xml | 2 ++ .../src/commonMain/composeResources/values-fr/strings.xml | 2 ++ .../src/commonMain/composeResources/values-it/strings.xml | 3 +++ .../src/commonMain/composeResources/values-ko/strings.xml | 1 + .../src/commonMain/composeResources/values-pl/strings.xml | 2 ++ .../src/commonMain/composeResources/values-ro/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-ru/strings.xml | 5 +++++ .../src/commonMain/composeResources/values-sr/strings.xml | 1 + .../src/commonMain/composeResources/values-srp/strings.xml | 1 + .../src/commonMain/composeResources/values-sv/strings.xml | 2 ++ .../src/commonMain/composeResources/values-uk/strings.xml | 2 ++ .../commonMain/composeResources/values-zh-rCN/strings.xml | 2 ++ .../commonMain/composeResources/values-zh-rTW/strings.xml | 2 ++ 19 files changed, 39 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml index cb615de37..21bf82cf3 100644 --- a/core/resources/src/commonMain/composeResources/values-be/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml @@ -180,6 +180,7 @@ Адлегласць Люкс Хуткасць + Паспрабаваць яшчэ раз Прашыўка Вольная памяць Налады diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index f69e137d9..865e05438 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -136,6 +136,8 @@ Няма намерени мрежови устройства Няма намерени USB устройства USB + BLE + USB Демо режим Свързан е с радио, но рядиото е в режим на заспиване Изисква се актуализация на приложението diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index d3e0566ac..65f334c3d 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -150,6 +150,7 @@ Nenalezena žádná síťová zařízení Nenalezena žádná USB zařízení USB + USB Demo režim Připojené k uspanému vysílači Aplikace je příliš stará diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 4755515ad..477888ada 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -176,6 +176,9 @@ Keine Netzwerkgeräte gefunden Kein USB-Gerät gefunden. USB + BLE + TCP + USB Demo Modus Mit Funkgerät verbunden, aber es ist im Schlafmodus Anwendungsaktualisierung erforderlich diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index 4c59aa547..7441bcea2 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -143,6 +143,7 @@ IP Ethernet: Conectando No está conectado + BLE Conectado a la radio, pero está en reposo Es necesario actualizar la aplicación Debe actualizar esta aplicación en la tienda de aplicaciones (o en Github). Es demasiado vieja para comunicarse con este firmware de radio. Por favor, lea nuestra documentación sobre este tema. @@ -635,6 +636,7 @@ Rango de Valores 0 - 500. Métricas de Energía Métricas del anfitrión Metadatos + Volver a intentar Acciones Software Utilizar el formato de 12h para el reloj diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index c2e327629..04f58ec33 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -176,6 +176,7 @@ Võrguseadmeid ei leitud USB seadmeid ei leitud USB + USB Demo režiim Ühendatud raadioga, aga see on unerežiimis Vajalik on rakenduse värskendus diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index f9da71dea..9dee83f15 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -176,6 +176,8 @@ Verkkolaitteita ei löytynyt USB-laitteita ei löytynyt USB + BLE + USB Esittelytila Yhdistetty radioon, mutta se on lepotilassa Sovelluspäivitys vaaditaan diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index f4afeef5c..cf2ee8ed4 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -176,6 +176,8 @@ Aucun périphérique réseau trouvé Pas de périphérique USB trouvé USB + BLE + USB Mode Démo Connecté à la radio, mais en mode veille Mise à jour de l’application requise diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index baa0e0947..a18f34ea4 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -172,6 +172,8 @@ Nessun dispositivo di rete trovato Nessun dispositivo USB trovato USB + BLE + USB Modalità Demo Connesso alla radio, ma sta dormendo Aggiornamento dell'applicazione necessario @@ -713,6 +715,7 @@ Metriche Host Metriche Pax Metadati + Riprova Azioni Firmware Usa formato orologio 12h diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml index 914446a60..2e193773c 100644 --- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml @@ -97,6 +97,7 @@ 연결됨 연결 중 연결되지 않음 + BLE 연결되었지만, 해당 장치는 절전모드입니다. 앱 업데이트가 필요합니다. 구글 플레이 스토어 또는 깃허브를 통해서 앱을 업데이트 해야합니다. 앱이 너무 구버전입니다. 이 주제의 docs 를 읽어주세요 diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 7c9b3433b..d12322b2e 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -156,6 +156,7 @@ Łączenie Nie połączono Nie wybrano urządzenia + BLE Połączono z urządzeniem, ale jest ono w stanie uśpienia Konieczna aktualizacja aplikacji Należy zaktualizować aplikację za pomocą Sklepu Play lub z GitHub, ponieważ aplikacja jest zbyt stara, by skomunikować się z oprogramowaniem zainstalowanym na tym urządzeniu. Więcej informacji (ang.). @@ -588,6 +589,7 @@ Metryki zasilania Statystyki hosta Metadane + Ponów próbę Oprogramowanie Użyj formatu 12-godzinnego Statystyki hosta diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index f9787ba93..f34a41fa5 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -173,6 +173,7 @@ Nici un dispozitiv de rețea găsit Niciun dispozitiv USB găsit USB + USB Mod demonstrativ Connectat la dispozitivi, dar e în modul de sleep Aplicație prea veche @@ -758,6 +759,7 @@ Valori Gazdă Valori Pax Metadate + Reîncercați Acţiuni Firmware Utilizaţi formatul ceasului 12h @@ -865,6 +867,8 @@ Ediţie firmware Dispozitive recente de rețea Dispozitive ale rețelei descoperite + Scanare… + Scanare… Dispozitive bluetooth disponibile Să începem Bine ai venit la diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index 8d4590e82..fc3821b01 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -176,6 +176,8 @@ Сетевые устройства не найдены Устройства USB не найдены USB + BLE + USB Демо-режим Подключен к радиостанции, но она спит Требуется обновление приложения @@ -799,6 +801,7 @@ Метрики хоста Метрика прохожих Метаданные + Повторить Действия Прошивка Использовать 12-часовой формат времени @@ -909,6 +912,8 @@ Версия прошивки Недавние сетевые устройства Найденные сетевые устройства + Поиск... + Поиск... Доступные Bluetooth-устройства Начать работу Добро пожаловать в diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index a365fc888..85cc314f9 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -124,6 +124,7 @@ IP adresa: Блутут повезан Nije povezan + БЛЕ Povezan na radio uređaj, ali uređaj je u stanju spavanja Nepohodno je ažuriranje aplikacije Morate da ažurirate ovu aplikaciju na prodavnici aplikacija (ili Github-u). Previše je stara da bi razgovarala sa ovim radio firmverom. Molimo vas da pročitate naše dokumente o ovoj temi. diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index 5bfbb0a84..9bdda2e65 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -124,6 +124,7 @@ IP адреса: Блутут повезан Није повезан + БЛЕ Повезан на радио уређај, али уређај је у стању спавања Неопходно је ажурирање апликације Морате ажурирати ову апликацију у продавници апликација (или на Гитхабу). Превише је стара да би могла комуницирати са овим радио фирмвером. Молимо вас да прочитате наша <a href='https://meshtastic.org/docs/software/android/installation'>документа</a> на ову тему. diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index 59e19f1e5..d71dba7d0 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -168,6 +168,7 @@ Ej ansluten Ingen enhet vald Okänd enhet + BLE Ansluten till radioenhet, men den är i sovläge Applikationen måste uppgraderas Du måste uppdatera detta program i app-butiken (eller Github). Det är för gammalt för att prata med denna radioenhet. Läs vår dokumentation i detta ämne. @@ -672,6 +673,7 @@ Strömdata Begär värdens värden Metadata + Försök igen Åtgärder Firmware Använd 12-timmarsformat diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index 7570440d6..77f0dcc9b 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -125,6 +125,7 @@ IP Ethernet: Під’єднання Не підключено + BLE Підключено до радіомодуля, але він в режимі сну Потрібне оновлення програми Ви повинні оновити цю програму в App Store (або Github). Він занадто старий, щоб спілкуватися з цією прошивкою радіо. Будь ласка, прочитайте нашу документацію у вказаній темі. @@ -517,6 +518,7 @@ Показники хоста Показники Pax Метадані + Повторити Дії Прошивка Використовувати 12-г формат часу 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 7fff0db20..e64cddf0c 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -174,6 +174,8 @@ 未找到网络设备 未找到USB设备。 USB + BLE + USB 演示模式 已连接至设备,但设备正在休眠中 需要更新应用程序 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 20ee6c639..1c973ef5f 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -176,6 +176,8 @@ 找不到網路裝置 找不到 USB 裝置 USB + 低功耗藍牙 + USB 展示模式 已連接裝置,但該裝置正在休眠中 需要應用程式更新 From 6547877e7db8d51105065b859df2cc88d65f4c8f Mon Sep 17 00:00:00 2001 From: jdogg172 Date: Wed, 22 Apr 2026 15:54:53 -0500 Subject: [PATCH 47/65] fix(ble): ensure GATT cleanup runs under NonCancellable on cancellation (#5207) Co-authored-by: James Rich Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../meshtastic/core/ble/KableBleConnection.kt | 9 +- .../core/network/radio/BleRadioTransport.kt | 21 +- .../BleRadioTransportReconnectCrashTest.kt | 273 ++++++++++++++++++ 3 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index f3b6d9383..bfc963fc4 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.job import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlin.concurrent.Volatile import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @@ -91,9 +92,11 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui */ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { - private var peripheral: Peripheral? = null - private var stateJob: Job? = null - private var connectionScope: CoroutineScope? = null + @Volatile private var peripheral: Peripheral? = null + + @Volatile private var stateJob: Job? = null + + @Volatile private var connectionScope: CoroutineScope? = null companion object { /** Settle delay between a direct connect failure and the autoConnect fallback attempt. */ 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 f2ba25804..40adf41e2 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 @@ -344,14 +344,27 @@ class BleRadioTransport( this@BleRadioTransport.callback.onConnect() } + } catch (e: CancellationException) { + // Scope was cancelled externally — still ensure GATT cleanup runs so we don't + // leak a BluetoothGatt handle and trigger GATT status 133 on the next attempt. + withContext(NonCancellable) { + try { + bleConnection.disconnect() + } catch (ignored: Exception) { + Logger.w(ignored) { "[$address] disconnect() failed during cancellation cleanup" } + } + } + throw e } catch (e: Exception) { Logger.w(e) { "[$address] Profile service discovery or operation failed" } // Disconnect to let the outer reconnect loop see a clean Disconnected state. // Do NOT call handleFailure here — the reconnect loop owns failure counting. - try { - bleConnection.disconnect() - } catch (ignored: Exception) { - Logger.w(ignored) { "[$address] disconnect() failed after profile error" } + withContext(NonCancellable) { + try { + bleConnection.disconnect() + } catch (ignored: Exception) { + Logger.w(ignored) { "[$address] disconnect() failed after profile error" } + } } } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt new file mode 100644 index 000000000..9b5cee7b7 --- /dev/null +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt @@ -0,0 +1,273 @@ +/* + * 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.network.radio + +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.ble.BleConnection +import org.meshtastic.core.ble.BleConnectionFactory +import org.meshtastic.core.ble.BleConnectionState +import org.meshtastic.core.ble.BleDevice +import org.meshtastic.core.ble.BleService +import org.meshtastic.core.ble.BleWriteType +import org.meshtastic.core.testing.FakeBleConnection +import org.meshtastic.core.testing.FakeBleConnectionFactory +import org.meshtastic.core.testing.FakeBleDevice +import org.meshtastic.core.testing.FakeBleScanner +import org.meshtastic.core.testing.FakeBluetoothRepository +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration + +/** + * Tests covering the BLE reconnect crash fixes in [BleRadioTransport]: + * 1. **CancellationException / GATT 133 fix**: [discoverServicesAndSetupCharacteristics] previously had a bare `catch + * (e: Exception)` that silently swallowed [CancellationException], meaning [BleConnection.disconnect] was never + * called when the scope was cancelled. This leaked the underlying BluetoothGatt handle and caused GATT status 133 on + * every subsequent reconnect. The fix adds an explicit `if (e is CancellationException)` branch that calls + * [disconnect] under [NonCancellable] before re-throwing. + * 2. **close() calls disconnect**: Verifies that calling [BleRadioTransport.close] triggers [BleConnection.disconnect] + * exactly once so the GATT handle is always released. + * 3. **Reconnect after failure respects policy backoff**: After a configurable number of consecutive failures the + * transport signals a transient (non-permanent) disconnect to the callback. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BleRadioTransportReconnectCrashTest { + + private val scanner = FakeBleScanner() + private val bluetoothRepository = FakeBluetoothRepository() + private val connection = FakeBleConnection() + private val connectionFactory = FakeBleConnectionFactory(connection) + private val service = mock(MockMode.autofill) + private val address = "AA:BB:CC:DD:EE:FF" + + @BeforeTest + fun setup() { + bluetoothRepository.setHasPermissions(true) + bluetoothRepository.setBluetoothEnabled(true) + } + + // ─── close() triggers disconnect ───────────────────────────────────────────────────────────── + + /** + * After [BleRadioTransport.close], [FakeBleConnection.disconnect] must be called. + * + * This validates the primary invariant introduced by the fix: GATT cleanup (disconnect) always runs — even when the + * coroutine scope is cancelled — by wrapping the call in [NonCancellable]. + */ + @Test + fun `close calls disconnect to clean up GATT handle`() = runTest { + val device = FakeBleDevice(address = address, name = "Test Radio") + bluetoothRepository.bond(device) + + val bleTransport = + BleRadioTransport( + scope = this, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = address, + ) + bleTransport.start() + + // Allow the connection loop to reach the connected state. + advanceTimeBy(4_000L) + + bleTransport.close() + + // disconnect() must be called: once by the connection loop teardown + once by close() itself. + // We only assert it was called at least once — the exact count depends on timing. + assertTrue(connection.disconnectCalls >= 1, "Expected disconnect() to be called at least once") + } + + // ─── disconnect called on connection failure ────────────────────────────────────────────────── + + /** + * When [FakeBleConnection.connectAndAwait] always returns [BleConnectionState.Disconnected], the transport must + * still eventually call [BleConnection.disconnect] to ensure the GATT handle state machine is reset before the next + * attempt. + * + * Virtual-time budget: DEFAULT_FAILURE_THRESHOLD (3) × (3 s settle + backoff) ≈ 24 s. + */ + @Test + fun `disconnect is called on connection failure`() = runTest { + val device = FakeBleDevice(address = address, name = "Test Radio") + bluetoothRepository.bond(device) + + // Make every connection attempt fail. + connection.failNextN = Int.MAX_VALUE + + val bleTransport = + BleRadioTransport( + scope = this, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = address, + ) + bleTransport.start() + + advanceTimeBy(30_000L) + + bleTransport.close() + + // Each failed connectAndAwait round-trips through the reconnect loop; close() always disconnects. + assertTrue(connection.disconnectCalls >= 1, "disconnect() not called after connection failure") + } + + // ─── transient onDisconnect after failure threshold ────────────────────────────────────────── + + /** + * Mirrors [BleRadioTransportTest.`onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`] but + * focuses specifically on the *reconnect* scenario introduced by the fix: after enough consecutive failures, the + * callback receives `isPermanent = false` — the transport keeps retrying rather than giving up permanently. + * + * Virtual time: 3 failures × (3 s settle + backoff starting at 5 s) ≈ 24 s. + */ + @Test + fun `transient onDisconnect is signalled after failure threshold without giving up`() = runTest { + val device = FakeBleDevice(address = address, name = "Test Radio") + bluetoothRepository.bond(device) + + connection.connectException = org.meshtastic.core.model.RadioNotConnectedException("simulated GATT failure") + + every { service.onDisconnect(any(), any()) } returns Unit + + val bleTransport = + BleRadioTransport( + scope = this, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = address, + ) + bleTransport.start() + + advanceTimeBy(24_001L) + + // Transient disconnect must have been signalled. + dev.mokkery.verify { service.onDisconnect(isPermanent = false, errorMessage = any()) } + // Permanent disconnect must NEVER be called by the transport on its own. + dev.mokkery.verify(mode = dev.mokkery.verify.VerifyMode.not) { + service.onDisconnect(isPermanent = true, errorMessage = any()) + } + + bleTransport.close() + } + + // ─── CancellationException is not silently swallowed ───────────────────────────────────────── + + /** + * [BleRadioTransport.close] cancels the [connectionScope]. The cancellation propagates as a [CancellationException] + * through the active coroutines in [discoverServicesAndSetupCharacteristics]. + * + * Before the fix, `catch (e: Exception)` swallowed the [CancellationException] and the `disconnect()` call was + * skipped. After the fix, [disconnect] is called under [NonCancellable]. + * + * This test uses a dedicated fake that throws [CancellationException] from [BleConnection.profile] to simulate the + * scope-cancellation path without races. + */ + @Test + fun `disconnect is called when profile setup throws CancellationException`() = runTest { + val throwingConnection = CancellingProfileBleConnection() + val throwingFactory = + object : BleConnectionFactory { + override fun create(scope: CoroutineScope, tag: String): BleConnection = throwingConnection + } + val device = FakeBleDevice(address = address, name = "Test Radio") + bluetoothRepository.bond(device) + + val bleTransport = + BleRadioTransport( + scope = this, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = throwingFactory, + callback = service, + address = address, + ) + bleTransport.start() + + // Allow one connection attempt to reach profile() and be cancelled. + advanceTimeBy(4_000L) + + bleTransport.close() + + assertTrue( + throwingConnection.disconnectCalls >= 1, + "disconnect() must be called after CancellationException in profile() — GATT leak fix", + ) + } +} + +// ─── Test doubles ──────────────────────────────────────────────────────────────────────────────── + +/** + * A [BleConnection] that succeeds at [connectAndAwait] but throws [CancellationException] from [profile]. This + * simulates what happens when the owning coroutine scope is cancelled while GATT service discovery is in progress. + */ +private class CancellingProfileBleConnection : BleConnection { + + private val _deviceFlow = MutableSharedFlow(replay = 1) + override val deviceFlow: SharedFlow = _deviceFlow.asSharedFlow() + + private val _connectionState = MutableSharedFlow(replay = 1) + override val connectionState: SharedFlow = _connectionState.asSharedFlow() + + override val device: BleDevice? = null + + var disconnectCalls = 0 + + override suspend fun connect(device: BleDevice) { + _deviceFlow.emit(device) + _connectionState.emit(BleConnectionState.Connected) + } + + override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState { + connect(device) + return BleConnectionState.Connected + } + + override suspend fun disconnect() { + disconnectCalls++ + _connectionState.emit(BleConnectionState.Disconnected()) + _deviceFlow.emit(null) + } + + override suspend fun profile( + serviceUuid: kotlin.uuid.Uuid, + timeout: Duration, + setup: suspend CoroutineScope.(BleService) -> T, + ): T = throw CancellationException("Simulated scope cancellation during service discovery") + + override fun maximumWriteValueLength(writeType: BleWriteType): Int? = null +} From 20e078d2d7e3f6543fac079f96339f849071c565 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:22:42 -0500 Subject: [PATCH 48/65] fix(ble): cleanup races discovered while reviewing #5207 (#5221) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/core/ble/KableBleConnection.kt | 24 +++++++++++++++---- .../core/network/radio/BleRadioTransport.kt | 14 ++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index bfc963fc4..a3808d3e6 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -139,10 +139,17 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } } ?: createPeripheral(device.address) { commonConfig() } - cleanUpPeripheral(device.address) - peripheral = p - - ActiveBleConnection.active = ActiveConnection(p, device.address) + // Install ownership of the new peripheral atomically. Cancellation between + // peripheral construction and field assignment would strand `p` (Kable allocates + // a per-peripheral scope + Bluetooth-state observer eagerly), so the cleanup, + // assignment, and ActiveBleConnection update must complete as a single unit. + // _deviceFlow.emit() is intentionally outside this block — making it + // non-cancellable could hang teardown on a slow collector. + withContext(NonCancellable) { + cleanUpPeripheral(device.address) + peripheral = p + ActiveBleConnection.active = ActiveConnection(p, device.address) + } _deviceFlow.emit(device) @@ -212,11 +219,18 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { stateJob?.cancel() stateJob = null + // Capture the peripheral we own before clearing it so we can identity-check + // ActiveBleConnection below. A stale disconnect from an earlier connection + // attempt's exception handler must not clobber a newer connection that has + // already installed itself as active. + val owned = peripheral safeClosePeripheral("disconnect") peripheral = null connectionScope = null - ActiveBleConnection.active = null + if (owned != null && ActiveBleConnection.active?.peripheral === owned) { + ActiveBleConnection.active = null + } _deviceFlow.emit(null) } 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 40adf41e2..9f1b530a8 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 @@ -103,9 +103,17 @@ class BleRadioTransport( internal val address: String, ) : RadioTransport { + // Detached cleanup scope for last-ditch GATT teardown from the exception handler. + // Must NOT be a child of `scope`: when an uncaught exception fires in connectionScope, + // upper layers often tear down `scope` immediately. Launching cleanup on `scope` then + // races the cancellation and may never start, leaking BluetoothGatt (status 133 on + // the next reconnect). This scope is cancelled in close() once our own disconnect + // has completed and the safety net is no longer needed. + private val cleanupScope: CoroutineScope = CoroutineScope(SupervisorJob() + scope.coroutineContext.minusKey(Job)) + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" } - scope.launch { + cleanupScope.launch { try { bleConnection.disconnect() } catch (e: Exception) { @@ -427,6 +435,10 @@ class BleRadioTransport( Logger.w(e) { "[$address] Failed to disconnect in close()" } } } + // Our own disconnect succeeded — the exception-handler safety net is no longer + // needed. Cancel the detached cleanup scope so it doesn't outlive us in tests + // or process lifetime. + cleanupScope.cancel("close() called") } private fun dispatchPacket(packet: ByteArray) { From 0b873be228aeaea33b35c822ef5f71f2e833ef1d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:35:22 -0500 Subject: [PATCH 49/65] fix(ble): unblock reconnect + kable audit (logging, priority, backoff, StateFlow) (#5222) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/app/di/KoinVerificationTest.kt | 7 ++ .../meshtastic/core/ble/KablePlatformSetup.kt | 10 ++- .../org/meshtastic/core/ble/BleConnection.kt | 16 ++-- .../meshtastic/core/ble/BleLoggingConfig.kt | 82 +++++++++++++++++++ .../org/meshtastic/core/ble/BleRetry.kt | 33 ++++++-- .../meshtastic/core/ble/KableBleConnection.kt | 38 ++++----- .../core/ble/KableBleConnectionFactory.kt | 4 +- .../meshtastic/core/ble/KableBleScanner.kt | 8 +- .../core/ble/KableMeshtasticRadioProfile.kt | 18 +++- .../meshtastic/core/ble/di/CoreBleModule.kt | 13 ++- .../core/network/radio/BleRadioTransport.kt | 42 ++++++---- .../BleRadioTransportReconnectCrashTest.kt | 80 +++++++++++++++--- .../org/meshtastic/core/testing/FakeBle.kt | 27 +++--- .../meshtastic/desktop/di/DesktopKoinTest.kt | 7 ++ .../ota/dfu/LegacyDfuTransportTest.kt | 6 +- .../ota/dfu/SecureDfuTransportTest.kt | 6 +- 16 files changed, 302 insertions(+), 95 deletions(-) create mode 100644 core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleLoggingConfig.kt diff --git a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt index 30e1b6be7..fd4b7aba8 100644 --- a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt @@ -30,6 +30,8 @@ import org.koin.test.verify.definition import org.koin.test.verify.injectedParameters import org.koin.test.verify.verify import org.meshtastic.app.map.MapViewModel +import org.meshtastic.core.ble.BleLogFormat +import org.meshtastic.core.ble.BleLogLevel import org.meshtastic.core.model.util.NodeIdLookup import org.meshtastic.feature.node.metrics.MetricsViewModel import kotlin.test.Test @@ -53,6 +55,11 @@ class KoinVerificationTest { NodeIdLookup::class, HttpClient::class, HttpClientEngine::class, + // BleLoggingConfig is a data class assembled by a factory function. Koin Verify + // still introspects its constructor params, so the wrapping enums need to be + // declared as known types even though they're never resolved from the graph. + BleLogLevel::class, + BleLogFormat::class, ), injections = injectedParameters( diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index 018d8f9fc..e9b4a58cc 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -43,15 +43,21 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn threadingStrategy = sharedThreadingStrategy + // We intentionally keep Kable's defaults for `transport` (Le) and `phy` (Le1M). + // Meshtastic radios (nRF52, ESP32-S3, RP2040+nRF) advertise BLE-only and don't support + // the LE 2M PHY in any first-party firmware, so changing these would be a regression risk + // with no upside. If a future hardware revision exposes 2M PHY, override `phy = Phy.Le2M` + // here after confirming the firmware advertises it. + onServicesDiscovered { try { // Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes. // Requesting the max MTU is critical for preventing dropped packets and stalls. @Suppress("MagicNumber") val negotiatedMtu = requestMtu(512) - Logger.i { "Negotiated MTU: $negotiatedMtu" } + Logger.i { "[${device.address}] Negotiated MTU: $negotiatedMtu" } } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "Failed to request MTU" } + Logger.w(e) { "[${device.address}] Failed to request MTU" } } } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt index 5a8b67ce1..4eb718e5b 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -18,7 +18,7 @@ package org.meshtastic.core.ble import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onStart import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -41,11 +41,17 @@ interface BleConnection { /** The currently connected [BleDevice], or null if not connected. */ val device: BleDevice? - /** A flow of the current device. */ - val deviceFlow: SharedFlow + /** + * A flow of the current device. [StateFlow] semantics: replays the latest value to new collectors and conflates + * rapid updates. + */ + val deviceFlow: StateFlow - /** A flow of [BleConnectionState] changes. */ - val connectionState: SharedFlow + /** + * A flow of [BleConnectionState] changes. [StateFlow] semantics ensure the latest state is always observable and + * distinct-equals deduplication avoids spurious re-emissions. + */ + val connectionState: StateFlow /** Connects to the given [BleDevice]. */ suspend fun connect(device: BleDevice) diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleLoggingConfig.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleLoggingConfig.kt new file mode 100644 index 000000000..9cd934368 --- /dev/null +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleLoggingConfig.kt @@ -0,0 +1,82 @@ +/* + * 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.ble + +import com.juul.kable.logs.Logging + +/** + * Verbosity for Kable's internal BLE logging. Wraps [Logging.Level] so callers and the Koin DI graph don't leak Kable + * types into modules that don't directly depend on Kable. + */ +enum class BleLogLevel { + /** Only failures (lowest noise; default in release builds). */ + Warnings, + + /** [Warnings] plus connect / disconnect / subscribe / GATT operation events. */ + Events, + + /** [Events] plus a hex dump of every read/write/notify payload. Very noisy. */ + Data, +} + +/** + * Format for Kable's internal BLE log entries. + * + * [Compact] keeps each entry on a single line — strongly preferred for `adb logcat`, grep, and bug reports. [Multiline] + * pretty-prints across several lines, which is harder to read in tooling. + */ +enum class BleLogFormat { + Compact, + Multiline, +} + +/** + * Verbosity and formatting controls for Kable's internal BLE logging. + * + * @see BleLogLevel for the verbosity scale. + * @see BleLogFormat for the layout choices. + */ +data class BleLoggingConfig(val level: BleLogLevel, val format: BleLogFormat = BleLogFormat.Compact) { + companion object { + /** Quiet defaults suitable for release builds — only warnings, single-line. */ + val Release: BleLoggingConfig = BleLoggingConfig(level = BleLogLevel.Warnings) + + /** Verbose defaults suitable for debug builds — every BLE event, single-line. */ + val Debug: BleLoggingConfig = BleLoggingConfig(level = BleLogLevel.Events) + } +} + +internal fun BleLogLevel.toKable(): Logging.Level = when (this) { + BleLogLevel.Warnings -> Logging.Level.Warnings + BleLogLevel.Events -> Logging.Level.Events + BleLogLevel.Data -> Logging.Level.Data +} + +internal fun BleLogFormat.toKable(): Logging.Format = when (this) { + BleLogFormat.Compact -> Logging.Format.Compact + BleLogFormat.Multiline -> Logging.Format.Multiline +} + +/** Applies this [BleLoggingConfig] to a Kable `logging { }` block, routing through [KermitLogEngine]. */ +internal fun Logging.applyConfig(config: BleLoggingConfig, identifier: String? = null) { + engine = KermitLogEngine + level = config.level.toKable() + format = config.format.toKable() + if (identifier != null) { + this.identifier = identifier + } +} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt index 5e85a52f8..a58b91aca 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt @@ -19,20 +19,33 @@ package org.meshtastic.core.ble import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import kotlin.math.pow +import kotlin.random.Random + +/** Cap on the per-attempt backoff to prevent unbounded growth. */ +private const val MAX_RETRY_DELAY_MS = 2_000L + +/** Multiplicative growth factor between attempts (delay doubles each time). */ +private const val BACKOFF_FACTOR = 2.0 /** - * Retries a BLE operation a specified number of times with a delay between attempts. + * Retries a BLE operation with bounded exponential backoff and jitter. * - * @param count The number of attempts to make. - * @param delayMs The delay in milliseconds between attempts. - * @param tag A tag for logging. + * Each retry waits `delayMs * 2^(attempt-1)`, capped at [MAX_RETRY_DELAY_MS], with a random ±25% jitter applied to + * avoid synchronised retry storms when multiple operations fail in lockstep (e.g. a TX/RX pair both failing the same + * `STATUS_GATT_BUSY` window). + * + * @param count Total attempt count (default 3). + * @param delayMs Initial delay before the first retry. Subsequent delays grow exponentially. + * @param tag Tag for log prefixes. * @param block The operation to perform. * @return The result of the operation. - * @throws Exception if the operation fails after all attempts. + * @throws Exception If the operation fails after all attempts. [CancellationException] is always re-thrown immediately. */ +@Suppress("MagicNumber") suspend fun retryBleOperation( count: Int = 3, - delayMs: Long = 500L, + delayMs: Long = 250L, tag: String = "BLE", block: suspend () -> T, ): T { @@ -48,8 +61,12 @@ suspend fun retryBleOperation( Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" } throw e } - Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." } - delay(delayMs) + val backoffMs = (delayMs * BACKOFF_FACTOR.pow(currentAttempt - 1)).toLong().coerceAtMost(MAX_RETRY_DELAY_MS) + val jitterRange = (backoffMs / 4).coerceAtLeast(1L) + val jitter = Random.nextLong(-jitterRange, jitterRange + 1) + val sleepMs = (backoffMs + jitter).coerceAtLeast(0L) + Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${sleepMs}ms..." } + delay(sleepMs) } } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index a3808d3e6..d64a88dde 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -22,18 +22,16 @@ import com.juul.kable.PeripheralBuilder import com.juul.kable.State import com.juul.kable.WriteType import com.juul.kable.characteristicOf -import com.juul.kable.logs.Logging import com.juul.kable.writeWithoutResponse import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.job @@ -90,7 +88,8 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui * fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller * ([BleRadioTransport]) owns the macro-level retry/backoff loop. */ -class KableBleConnection(private val scope: CoroutineScope) : BleConnection { +class KableBleConnection(private val scope: CoroutineScope, private val loggingConfig: BleLoggingConfig) : + BleConnection { @Volatile private var peripheral: Peripheral? = null @@ -103,19 +102,15 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { private val AUTOCONNECT_FALLBACK_DELAY = 1.seconds } - private val _deviceFlow = MutableSharedFlow(replay = 1) - override val deviceFlow: SharedFlow = _deviceFlow.asSharedFlow() + private val _deviceFlow = MutableStateFlow(null) + override val deviceFlow: StateFlow = _deviceFlow.asStateFlow() override val device: BleDevice? - get() = _deviceFlow.replayCache.firstOrNull() + get() = _deviceFlow.value private val _connectionState = - MutableSharedFlow( - replay = 1, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - override val connectionState: SharedFlow = _connectionState.asSharedFlow() + MutableStateFlow(BleConnectionState.Disconnected(DisconnectReason.Unknown)) + override val connectionState: StateFlow = _connectionState.asStateFlow() @Suppress("CyclomaticComplexMethod", "LongMethod") override suspend fun connect(device: BleDevice) { @@ -124,11 +119,7 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { /** Applies logging, observation exception handling, and platform config shared by both peripheral types. */ fun PeripheralBuilder.commonConfig() { - logging { - engine = KermitLogEngine - level = Logging.Level.Events - identifier = device.address - } + logging { applyConfig(loggingConfig, identifier = device.address) } observationExceptionHandler { cause -> Logger.w(cause) { "[${device.address}] Observation failure suppressed" } } @@ -182,8 +173,11 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { throw e } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) { if (autoConnect) { - // autoConnect already true and still failed — don't loop forever. - Logger.w { "[${device.address}] autoConnect attempt failed, giving up" } + // Already on the autoConnect path and still failing: surface a clear Disconnected + // and let the outer reconnect loop (BleRadioTransport) own the macro retry budget. + Logger.w { + "[${device.address}] autoConnect attempt also failed; deferring to outer reconnect loop" + } _connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed)) throw e } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt index 13b8a1663..8fb34aa3b 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt @@ -20,12 +20,12 @@ import kotlinx.coroutines.CoroutineScope import org.koin.core.annotation.Single @Single -class KableBleConnectionFactory : BleConnectionFactory { +class KableBleConnectionFactory(private val loggingConfig: BleLoggingConfig) : BleConnectionFactory { /** * Creates a new [KableBleConnection]. * * [tag] is unused because Kable's own log identifier is set per-peripheral inside [KableBleConnection.connect] * using the device address, which provides more precise context than a factory-time tag. */ - override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope) + override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope, loggingConfig) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt index 5e91b3459..875978ceb 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.ble import com.juul.kable.Scanner -import com.juul.kable.logs.Logging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withTimeoutOrNull @@ -26,13 +25,10 @@ import kotlin.time.Duration import kotlin.uuid.Uuid @Single -class KableBleScanner : BleScanner { +class KableBleScanner(private val loggingConfig: BleLoggingConfig) : BleScanner { override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow { val scanner = Scanner { - logging { - engine = KermitLogEngine - level = Logging.Level.Events - } + logging { applyConfig(loggingConfig) } // Use separate match blocks so each filter is evaluated independently (OR semantics). // Combining address and service UUID in a single match{} creates an AND filter which // silently drops results on OEM stacks (Samsung, Xiaomi) when the device uses a diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt index 3f0e61864..8ecb253bf 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt @@ -47,15 +47,24 @@ class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticR private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC) private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC) + /** + * Cached preferred write type for [toRadio]. Resolved once at construction so the hot send path doesn't have to + * walk the discovered services list on every packet. + */ + private val toRadioWriteType: BleWriteType = service.preferredWriteType(toRadio) + companion object { private val TRANSIENT_RETRY_DELAY = 500.milliseconds } private val subscriptionReady = CompletableDeferred() - /** Seed with replay=1 so the config-handshake drain starts before FROMNUM notifications are gated in. */ + /** + * Latched signal: a single buffered slot collapses bursts of drain triggers into one pending poll. Capacity 1 with + * DROP_OLDEST means we never block writers and never let stale drain requests pile up. + */ private val triggerDrain = - MutableSharedFlow(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) + MutableSharedFlow(replay = 1, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) @Suppress("TooGenericExceptionCaught", "SwallowedException") override val fromRadio: Flow = channelFlow { @@ -97,14 +106,15 @@ class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticR if (service.hasCharacteristic(logRadioChar)) { service.observe(logRadioChar).catch { e -> if (e is CancellationException) throw e - // logRadio is optional — swallow observation errors silently. + // logRadio is optional — log at debug for diagnostics but don't surface to callers. + Logger.d(e) { "logRadio observation failure suppressed" } } } else { emptyFlow() } override suspend fun sendToRadio(packet: ByteArray) { - service.write(toRadio, packet, service.preferredWriteType(toRadio)) + service.write(toRadio, packet, toRadioWriteType) triggerDrain.tryEmit(Unit) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/di/CoreBleModule.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/di/CoreBleModule.kt index f064fcb63..6302c4af1 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/di/CoreBleModule.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/di/CoreBleModule.kt @@ -18,7 +18,18 @@ package org.meshtastic.core.ble.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.meshtastic.core.ble.BleLoggingConfig +import org.meshtastic.core.common.BuildConfigProvider @Module @ComponentScan("org.meshtastic.core.ble") -class CoreBleModule +class CoreBleModule { + /** + * Quiet by default in release; verbose (Kable [Events][com.juul.kable.logs.Logging.Level.Events]) in debug builds. + * Always single-line for grep/logcat friendliness. + */ + @Single + fun provideBleLoggingConfig(buildConfig: BuildConfigProvider): BleLoggingConfig = + if (buildConfig.isDebug) BleLoggingConfig.Debug else BleLoggingConfig.Release +} 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 9f1b530a8..7da67a54a 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 @@ -26,9 +26,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -258,24 +258,25 @@ class BleRadioTransport( isFullyConnected = true onConnected() - // Scope the connectionState listener to this iteration so it's - // cancelled automatically before the next reconnect cycle. - var disconnectReason: DisconnectReason = DisconnectReason.Unknown - coroutineScope { - bleConnection.connectionState - .onEach { s -> - if (s is BleConnectionState.Disconnected && isFullyConnected) { - isFullyConnected = false - disconnectReason = s.reason - onDisconnected() - } - } - .catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } } - .launchIn(this) + discoverServicesAndSetupCharacteristics() - discoverServicesAndSetupCharacteristics() + // Wait for the StateFlow to actually reflect Connected before watching for the next + // Disconnected. connectAndAwait returns synchronously based on the underlying Kable + // peripheral state, but our _connectionState observer runs on a separate coroutine and + // may lag. Without this gate the next .first { Disconnected } below could match the + // *previous* cycle's stale Disconnected value and fire immediately, breaking reconnect. + bleConnection.connectionState.first { it is BleConnectionState.Connected } - bleConnection.connectionState.first { it is BleConnectionState.Disconnected } + // Suspend until the next Disconnected emission. We deliberately do NOT wrap this in a + // coroutineScope { launchIn(...); first(...) } pattern: launching a hot StateFlow + // collector inside coroutineScope hangs the scope after .first returns (the launched + // collector never completes naturally, and coroutineScope waits for all children). + val disconnectedState = + bleConnection.connectionState.filterIsInstance().first() + val disconnectReason = disconnectedState.reason + if (isFullyConnected) { + isFullyConnected = false + onDisconnected() } Logger.i { "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" } @@ -350,6 +351,13 @@ class BleRadioTransport( val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" } + // Ask the platform for a low-latency / high-throughput connection interval + // (~7.5 ms on Android). The Meshtastic firmware happily accepts this and it + // materially speeds up the initial config drain and any bulk fromRadio reads. + if (bleConnection.requestHighConnectionPriority()) { + Logger.d { "[$address] Requested high BLE connection priority" } + } + this@BleRadioTransport.callback.onConnect() } } catch (e: CancellationException) { diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt index 9b5cee7b7..c1835e788 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt @@ -24,9 +24,9 @@ import dev.mokkery.mock import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.meshtastic.core.ble.BleConnection @@ -35,6 +35,7 @@ import org.meshtastic.core.ble.BleConnectionState import org.meshtastic.core.ble.BleDevice import org.meshtastic.core.ble.BleService import org.meshtastic.core.ble.BleWriteType +import org.meshtastic.core.ble.DisconnectReason import org.meshtastic.core.testing.FakeBleConnection import org.meshtastic.core.testing.FakeBleConnectionFactory import org.meshtastic.core.testing.FakeBleDevice @@ -227,6 +228,63 @@ class BleRadioTransportReconnectCrashTest { "disconnect() must be called after CancellationException in profile() — GATT leak fix", ) } + + // ─── Reconnect after a stable connection drops ─────────────────────────────────────────────── + + /** + * Regression test for the BLE reconnect hang. + * + * Symptom: after a stable connection (uptime > minStableConnection) was terminated by a remote disconnect (e.g. + * node power-cycle), the transport's reconnect loop never iterated — `attemptConnection` ran exactly once, the GATT + * disconnect callback fired, and then nothing. + * + * Root cause: `attemptConnection` wrapped its disconnect-watcher in a `coroutineScope { + * connectionState.onEach{...}.launchIn(this); connectionState.first { Disconnected } }` block. `coroutineScope` + * waits for ALL launched children before returning, but the `.launchIn` collector on a hot `StateFlow` (or + * `SharedFlow(replay=1)`) never completes naturally. After `.first` returned, the scope hung forever, blocking + * `BleReconnectPolicy.execute` from issuing the next attempt. + * + * This test exercises the full happy-path reconnect cycle: connect → stable uptime → external disconnect → expect a + * second `connectAndAwait` call. With the bug present, only one `connectAndAwait` call ever happens. + */ + @Test + fun `transport reconnects after a stable connection is dropped remotely`() = runTest { + val device = FakeBleDevice(address = address, name = "Test Radio") + bluetoothRepository.bond(device) + + val bleTransport = + BleRadioTransport( + scope = this, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + callback = service, + address = address, + ) + bleTransport.start() + + // Settle delay (3 s) + connect + handshake. + advanceTimeBy(4_000L) + assertTrue(connection.connectAndAwaitCalls == 1, "First connect must happen during initial start window") + + // Stay connected long enough to be considered stable (> minStableConnection = 5 s). + advanceTimeBy(10_000L) + + // Simulate the firmware dying mid-session — the same path a node power-cycle takes. + connection.simulateRemoteDisconnect(reason = DisconnectReason.Timeout) + + // Settle delay (3 s) before the next attempt + re-connect window. Generous to absorb + // the policy retry backoff (5 s on first failure) plus another 3 s settle delay. + advanceTimeBy(30_000L) + + assertTrue( + connection.connectAndAwaitCalls >= 2, + "Reconnect loop must call connectAndAwait again after a remote disconnect " + + "(actual calls: ${connection.connectAndAwaitCalls})", + ) + + bleTransport.close() + } } // ─── Test doubles ──────────────────────────────────────────────────────────────────────────────── @@ -237,19 +295,19 @@ class BleRadioTransportReconnectCrashTest { */ private class CancellingProfileBleConnection : BleConnection { - private val _deviceFlow = MutableSharedFlow(replay = 1) - override val deviceFlow: SharedFlow = _deviceFlow.asSharedFlow() + private val _deviceFlow = MutableStateFlow(null) + override val deviceFlow: StateFlow = _deviceFlow.asStateFlow() - private val _connectionState = MutableSharedFlow(replay = 1) - override val connectionState: SharedFlow = _connectionState.asSharedFlow() + private val _connectionState = MutableStateFlow(BleConnectionState.Disconnected()) + override val connectionState: StateFlow = _connectionState.asStateFlow() override val device: BleDevice? = null var disconnectCalls = 0 override suspend fun connect(device: BleDevice) { - _deviceFlow.emit(device) - _connectionState.emit(BleConnectionState.Connected) + _deviceFlow.value = device + _connectionState.value = BleConnectionState.Connected } override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState { @@ -259,8 +317,8 @@ private class CancellingProfileBleConnection : BleConnection { override suspend fun disconnect() { disconnectCalls++ - _connectionState.emit(BleConnectionState.Disconnected()) - _deviceFlow.emit(null) + _connectionState.value = BleConnectionState.Disconnected() + _deviceFlow.value = null } override suspend fun profile( diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt index f2001da86..bed4b1146 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt @@ -20,9 +20,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow @@ -36,6 +34,7 @@ import org.meshtastic.core.ble.BleService import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.ble.BluetoothState +import org.meshtastic.core.ble.DisconnectReason import kotlin.time.Duration import kotlin.uuid.Uuid @@ -91,11 +90,10 @@ class FakeBleConnection : override val device: BleDevice? get() = _device.value - private val _deviceFlow = mutableSharedFlow(replay = 1) - override val deviceFlow: SharedFlow = _deviceFlow.asSharedFlow() + override val deviceFlow: StateFlow = _device.asStateFlow() - private val _connectionState = mutableSharedFlow(replay = 1) - override val connectionState: SharedFlow = _connectionState.asSharedFlow() + private val _connectionState = mutableStateFlow(BleConnectionState.Disconnected()) + override val connectionState: StateFlow = _connectionState.asStateFlow() /** When > 0, the next [failNextN] calls to [connectAndAwait] return [BleConnectionState.Disconnected]. */ var failNextN: Int = 0 @@ -109,6 +107,14 @@ class FakeBleConnection : /** Number of times [disconnect] has been invoked. */ var disconnectCalls: Int = 0 + /** Number of times [connectAndAwait] has been invoked (including failures). */ + var connectAndAwaitCalls: Int = 0 + + /** Externally simulate a remote disconnect (e.g. node power-cycle) for tests that exercise reconnect. */ + fun simulateRemoteDisconnect(reason: DisconnectReason = DisconnectReason.Timeout) { + _connectionState.value = BleConnectionState.Disconnected(reason) + } + /** Service UUIDs that should appear missing — `profile()` throws `NoSuchElementException` for these. */ val missingServices: MutableSet = mutableSetOf() @@ -116,18 +122,18 @@ class FakeBleConnection : override suspend fun connect(device: BleDevice) { _device.value = device - _deviceFlow.emit(device) - _connectionState.emit(BleConnectionState.Connecting) + _connectionState.value = BleConnectionState.Connecting if (device is FakeBleDevice) { device.setState(BleConnectionState.Connecting) } - _connectionState.emit(BleConnectionState.Connected) + _connectionState.value = BleConnectionState.Connected if (device is FakeBleDevice) { device.setState(BleConnectionState.Connected) } } override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState { + connectAndAwaitCalls++ connectException?.let { throw it } if (failNextN > 0) { failNextN-- @@ -140,12 +146,11 @@ class FakeBleConnection : override suspend fun disconnect() { disconnectCalls++ val currentDevice = _device.value - _connectionState.emit(BleConnectionState.Disconnected()) + _connectionState.value = BleConnectionState.Disconnected() if (currentDevice is FakeBleDevice) { currentDevice.setState(BleConnectionState.Disconnected()) } _device.value = null - _deviceFlow.emit(null) } override suspend fun profile( diff --git a/desktop/src/test/kotlin/org/meshtastic/desktop/di/DesktopKoinTest.kt b/desktop/src/test/kotlin/org/meshtastic/desktop/di/DesktopKoinTest.kt index b1136e71a..d18626660 100644 --- a/desktop/src/test/kotlin/org/meshtastic/desktop/di/DesktopKoinTest.kt +++ b/desktop/src/test/kotlin/org/meshtastic/desktop/di/DesktopKoinTest.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.CoroutineDispatcher import org.koin.core.annotation.KoinExperimentalAPI import org.koin.dsl.module import org.koin.test.verify.verify +import org.meshtastic.core.ble.BleLogFormat +import org.meshtastic.core.ble.BleLogLevel import kotlin.test.Test @OptIn(KoinExperimentalAPI::class) @@ -41,6 +43,11 @@ class DesktopKoinTest { CoroutineDispatcher::class, HttpClient::class, HttpClientEngine::class, + // BleLoggingConfig is a data class assembled by a factory function. Koin Verify + // still introspects its constructor params, so the wrapping enums need to be + // declared as known types even though they're never resolved from the graph. + BleLogLevel::class, + BleLogFormat::class, ), ) } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt index 4504e460d..42ddaad70 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransportTest.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.runTest import org.meshtastic.core.ble.BleCharacteristic import org.meshtastic.core.ble.BleConnection @@ -510,10 +510,10 @@ class LegacyDfuTransportTest { override val device: BleDevice? get() = delegate.device - override val deviceFlow: SharedFlow + override val deviceFlow: StateFlow get() = delegate.deviceFlow - override val connectionState: SharedFlow + override val connectionState: StateFlow get() = delegate.connectionState override suspend fun connect(device: BleDevice) = delegate.connect(device) diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt index 454aadaa2..964916d51 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt @@ -21,7 +21,7 @@ package org.meshtastic.feature.firmware.ota.dfu import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.runTest import org.meshtastic.core.ble.BleCharacteristic import org.meshtastic.core.ble.BleConnection @@ -632,10 +632,10 @@ class SecureDfuTransportTest { override val device: BleDevice? get() = delegate.device - override val deviceFlow: SharedFlow + override val deviceFlow: StateFlow get() = delegate.deviceFlow - override val connectionState: SharedFlow + override val connectionState: StateFlow get() = delegate.connectionState override suspend fun connect(device: BleDevice) = delegate.connect(device) From 9dae0f5c8b6c41163a214dc8cb2f74bc68c1458a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:48:12 -0500 Subject: [PATCH 50/65] chore(deps): update devtools.ksp to v2.3.7 (#5223) 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 9ff0f8c3a..32790eb40 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ datadog-gradle = "1.25.0" dd-sdk-android = "3.9.0" detekt = "1.23.8" dokka = "2.2.0" -devtools-ksp = "2.3.6" +devtools-ksp = "2.3.7" firebase-crashlytics-gradle = "3.0.7" google-services-gradle = "4.4.4" markdownRenderer = "0.40.2" From e501adef5639a01203a64239688e701a2c6218e6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:00:27 -0500 Subject: [PATCH 51/65] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5224) --- .../src/commonMain/composeResources/values-bg/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-cs/strings.xml | 2 ++ .../src/commonMain/composeResources/values-de/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-es/strings.xml | 1 + .../src/commonMain/composeResources/values-et/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-fi/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-fr/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-it/strings.xml | 1 + .../src/commonMain/composeResources/values-pl/strings.xml | 1 + .../src/commonMain/composeResources/values-ro/strings.xml | 1 + .../src/commonMain/composeResources/values-ru/strings.xml | 1 + .../src/commonMain/composeResources/values-sr/strings.xml | 1 + .../src/commonMain/composeResources/values-srp/strings.xml | 1 + .../src/commonMain/composeResources/values-sv/strings.xml | 1 + .../src/commonMain/composeResources/values-uk/strings.xml | 1 + .../src/commonMain/composeResources/values-zh-rCN/strings.xml | 2 ++ .../src/commonMain/composeResources/values-zh-rTW/strings.xml | 4 ++++ 17 files changed, 37 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index 865e05438..b89322e0f 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -629,6 +629,7 @@ Показатели на качеството на въздуха Показатели на мощност Метаданни + Опитайте отново Действия Фърмуер Използване на 12ч формат @@ -726,7 +727,10 @@ Версия на фърмуера Скорошни мрежови устройства Открити мрежови устройства + Сканиране… + Сканиране… Налични Bluetooth устройства + Няма свързано устройство Започнете Добре дошли в Останете свързани навсякъде diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index 65f334c3d..b3e88dcf1 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -662,6 +662,7 @@ Metriky kvality ovzduší Metriky napájení Metadata + Zkusit znovu Akce Firmware Použít 12h formát hodin @@ -748,6 +749,7 @@ Nedávná síťová zařízení Nalezená síťová zařízení Dostupná Bluetooth zařízení + Není připojeno žádné zařízení Začněte hned Vítejte v Zůstaňte připojeni kdekoliv diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 477888ada..592633688 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -794,6 +794,7 @@ Host Kennzahlen Benutzerzählerdaten Metadaten + Erneut versuchen Aktionen Firmware 12h Uhrformat verwenden @@ -904,7 +905,10 @@ Firmware-Version Kürzliche Netzwerkgeräte Entdeckte Netzwerkgeräte + Suche... + Suche... Verfügbare Bluetooth Geräte + Kein Gerät verbunden Erste Schritte Willkommen bei Bleibe überall in Verbindung diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index 7441bcea2..b9dd520c3 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -725,6 +725,7 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Edición de firmware Dispositivos de red recientes Dispositivos de red descubiertos + No hay dispositivos conectados Empezar Bienvenido a Manténgase conectado en cualquier lugar diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 04f58ec33..ff520d4c8 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -792,6 +792,7 @@ Hosti mõõdik Pax mõõdiku küsimine Metaandmed + Proovi uuesti Toimingud Püsivara Kasuta 12 tunni formaati @@ -902,7 +903,10 @@ Püsivara versioon Hiljuti nähtud seadmed Avastatud seadmed + Otsin… + Otsin… Saadaval olevad sinhamba seadmed + Ühtegi seadet pole ühendatud Algusesse Teretulemast Igal pool ühenduses diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index 9dee83f15..e87b9f452 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -793,6 +793,7 @@ Isäntälaitteen mittausarvot Pax mittarit Metatiedot + Yritä uudelleen Toiminnot Laiteohjelmisto Käytä 12 tunnin kelloa @@ -903,7 +904,10 @@ Laiteohjelmistoversio Äskettäin havaitut verkkolaitteet Löydetyt verkkolaitteet + Etsitään… + Etsitään… Saatavilla olevat Bluetooth-laitteet + Ei laitetta kytkettynä Näin pääset alkuun Tervetuloa Pysy yhteydessä kaikkialla diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index cf2ee8ed4..e442b0f98 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -783,6 +783,7 @@ Métriques de l’hôte Métriques de Pax Métadonnées + Réessayer Actions Micrologiciel Utiliser le format horaire 12h @@ -893,7 +894,10 @@ Version du micrologiciel Périphériques réseaux récents Appareils réseau découverts + Recherche… + Recherche… Périphériques Bluetooth disponibles + Aucun appareil connecté Commencer Bienvenue sur Restez connecté n'importe où diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index a18f34ea4..205ab60f0 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -815,6 +815,7 @@ Dispositivi di rete recenti Dispositivi di rete rilevati Dispositivi Bluetooth Disponibili + Nessun dispositivo connesso Inizia ora Benvenuto a Rimani connesso ovunque diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index d12322b2e..fb141af2d 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -644,6 +644,7 @@ Obecnie zainstalowana wersja Ostatnia stabilna wersja Ostatnia wersja alpha + Brak podłączonych urządzeń Udostępniaj swoją lokalizację w czasie rzeczywistym i koordynuj działania swojej grupy dzięki zintegrowanym funkcjom GPS. Powiadomienia aplikacji Wiadomości przychodzące diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index f34a41fa5..256710470 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -870,6 +870,7 @@ Scanare… Scanare… Dispozitive bluetooth disponibile + Niciun dispozitiv conectat Să începem Bine ai venit la Rămâneţi conectat oriunde diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index fc3821b01..e975c5e37 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -915,6 +915,7 @@ Поиск... Поиск... Доступные Bluetooth-устройства + Нет подключенных устройств Начать работу Добро пожаловать в Оставайтесь на связи везде diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index 85cc314f9..39f560ae6 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -408,6 +408,7 @@ Отпусти Обриши поруке? Порука + Нема повезаних уређаја подешавања Сателит Хибридни diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index 9bdda2e65..f4a4c211b 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -408,6 +408,7 @@ Отпусти Обриши поруке? Порука + Нема повезаних уређаја подешавања Сателит Хибридни diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index d71dba7d0..6fef6870a 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -771,6 +771,7 @@ Senaste nätverksenheterna Upptäckta nätverksenheter Tillgängliga blåtandsenheter + Ingen ansluten enhet Kom igång Välkommen till Håll dig uppkopplad var som helst diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index 77f0dcc9b..c10440981 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -582,6 +582,7 @@ Підтримується спільнотою 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 e64cddf0c..fc5a6d8c2 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -725,6 +725,7 @@ 主机测量 Pax 计量 元数据 + 重试 操作 固件 使用 12 小时制格式 @@ -824,6 +825,7 @@ 最近使用的网络设备 发现的网络设备 可用的蓝牙设备 + 设备未连接 开始 欢迎使用 随时随地保持联系 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 1c973ef5f..e99300c67 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -787,6 +787,7 @@ 主機資訊 人流計量資料 中繼資料 + 重試 動作 韌體 使用12小時制 @@ -897,7 +898,10 @@ 韌體版本 最近的網路裝置 發現的網路裝置 + 正在搜尋… + 正在搜尋… 可連接的藍牙裝置 + 尚未連線裝置 開始使用 歡迎來到 隨時隨地保持連線 From cf834a77f6341102f76742930893686613f066f9 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:04:16 -0500 Subject: [PATCH 52/65] feat: Enhance mPWRD-os WiFi provisioning success state and UI components (#5225) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../composeResources/values/strings.xml | 15 ++ .../wifiprovision/NymeaBleConstants.kt | 6 + .../wifiprovision/WifiProvisionViewModel.kt | 4 + .../wifiprovision/domain/NymeaProtocol.kt | 17 ++ .../wifiprovision/domain/NymeaWifiService.kt | 28 +++- .../wifiprovision/model/WifiNetwork.kt | 5 +- .../wifiprovision/ui/WifiProvisionPreviews.kt | 8 + .../wifiprovision/ui/WifiProvisionScreen.kt | 153 ++++++++++++++++++ .../WifiProvisionViewModelTest.kt | 5 +- .../wifiprovision/domain/NymeaProtocolTest.kt | 9 ++ .../domain/NymeaWifiServiceTest.kt | 23 ++- 11 files changed, 264 insertions(+), 9 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 0e8334a4b..4021bc8aa 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1306,6 +1306,21 @@ Enter or select a network WiFi configured successfully! Failed to apply WiFi configuration + Device Connected + Your mPWRD-OS device has joined the Wi-Fi network. + IP Address + Complete Device Setup + Sign in over SSH to change the default username and password. + Username + root + 1234 + SSH Command + ssh %1$s@%2$s + SSH command available after IP is assigned. + Open SSH Client + If no app opens, copy the SSH command and paste it into your SSH client. + IP unavailable + Done Meshtastic Desktop Show Meshtastic Quit diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt index 5b0d8398c..8f8051dcd 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt @@ -70,6 +70,9 @@ internal object NymeaBleConstants { /** Maximum time to wait for a command response. */ val RESPONSE_TIMEOUT = 15.seconds + /** Timeout for optional GetConnection metadata lookup after a successful connect command. */ + val CONNECTION_INFO_TIMEOUT = 2.seconds + /** Settle time after subscribing to notifications before sending commands. */ val SUBSCRIPTION_SETTLE = 300.milliseconds // endregion @@ -87,6 +90,9 @@ internal object NymeaBleConstants { /** Trigger a fresh WiFi scan. */ const val CMD_SCAN = 4 + + /** Request current connection details (includes IP address if connected). */ + const val CMD_GET_CONNECTION = 5 // endregion // region Response error codes diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt index 6dbb8c676..7a146df89 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt @@ -42,6 +42,8 @@ data class WifiProvisionUiState( val error: WifiProvisionError? = null, /** Name of the BLE device we connected to, shown in the DeviceFound confirmation. */ val deviceName: String? = null, + /** IPv4 address reported by nymea after successful provisioning (if available). */ + val ipAddress: String? = null, /** Provisioning outcome shown as inline status (matches web flasher pattern). */ val provisionStatus: ProvisionStatus = ProvisionStatus.Idle, ) { @@ -175,6 +177,7 @@ class WifiProvisionViewModel( it.copy( phase = WifiProvisionUiState.Phase.Provisioning, error = null, + ipAddress = null, provisionStatus = WifiProvisionUiState.ProvisionStatus.Idle, ) } @@ -186,6 +189,7 @@ class WifiProvisionViewModel( _uiState.update { it.copy( phase = WifiProvisionUiState.Phase.Connected, + ipAddress = result.ipAddress, provisionStatus = WifiProvisionUiState.ProvisionStatus.Success, ) } diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt index 71fe68f79..846bc809a 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt @@ -75,6 +75,8 @@ internal data class NymeaResponse( @SerialName("c") val command: Int = -1, /** 0 = success; non-zero = error code. */ @SerialName("r") val responseCode: Int = 0, + /** Optional payload (used by GetConnection and custom Connect responses). */ + @SerialName("p") val connectionInfo: NymeaConnectionInfo? = null, ) /** One entry in the GetNetworks (`c=0`) response payload. */ @@ -97,3 +99,18 @@ internal data class NymeaNetworksResponse( @SerialName("r") val responseCode: Int = 0, @SerialName("p") val networks: List = emptyList(), ) + +/** Connection info payload (`p`) returned by GetConnection (`c=5`). */ +@Serializable +internal data class NymeaConnectionInfo( + /** ESSID / network name (nymea key: `e`). */ + @SerialName("e") val ssid: String = "", + /** BSSID / MAC address (nymea key: `m`). */ + @SerialName("m") val bssid: String = "", + /** Signal strength in dBm (nymea key: `s`). */ + @SerialName("s") val signalStrength: Int = 0, + /** 0 = open, 1 = protected (nymea key: `p`). */ + @SerialName("p") val protection: Int = 0, + /** IPv4 address of current connection (nymea key: `i`). */ + @SerialName("i") val ipAddress: String = "", +) diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt index 75dc15256..f78c7323c 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt @@ -39,9 +39,11 @@ import org.meshtastic.core.common.util.safeCatching import org.meshtastic.feature.wifiprovision.NymeaBleConstants import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT_HIDDEN +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_GET_CONNECTION import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_GET_NETWORKS import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_SCAN import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CONNECTION_INFO_TIMEOUT import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_SUCCESS import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_TIMEOUT import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SCAN_TIMEOUT @@ -50,6 +52,7 @@ import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_COMMANDER import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_SERVICE_UUID import org.meshtastic.feature.wifiprovision.model.ProvisionResult import org.meshtastic.feature.wifiprovision.model.WifiNetwork +import kotlin.time.Duration /** * GATT client for the nymea-networkmanager WiFi provisioning profile. @@ -68,7 +71,6 @@ class NymeaWifiService( connectionFactory: BleConnectionFactory, dispatcher: CoroutineDispatcher, ) { - private val serviceScope = CoroutineScope(SupervisorJob() + dispatcher) private val bleConnection = connectionFactory.create(serviceScope, TAG) @@ -184,7 +186,9 @@ class NymeaWifiService( sendCommand(json) val response = NymeaJson.decodeFromString(waitForResponse()) if (response.responseCode == RESPONSE_SUCCESS) { - ProvisionResult.Success + val ipAddress = + response.connectionInfo?.ipAddress?.takeIf { it.isNotBlank() } ?: fetchConnectionIpAddress() + ProvisionResult.Success(ipAddress = ipAddress) } else { ProvisionResult.Failure(response.responseCode, nymeaErrorMessage(response.responseCode)) } @@ -229,7 +233,25 @@ class NymeaWifiService( } /** Wait up to [RESPONSE_TIMEOUT] for a complete JSON response from the notification channel. */ - private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT) { responseChannel.receive() } + private suspend fun waitForResponse(timeout: Duration = RESPONSE_TIMEOUT): String = + withTimeout(timeout) { responseChannel.receive() } + + /** + * Best-effort query for current connection info (`CMD_GET_CONNECTION`), returning the reported IP address. + * + * Uses a short timeout because this is an optional enrichment for UX, not a provisioning success criterion. + */ + private suspend fun fetchConnectionIpAddress(): String? = safeCatching { + sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_GET_CONNECTION))) + val response = + NymeaJson.decodeFromString(waitForResponse(timeout = CONNECTION_INFO_TIMEOUT)) + if (response.responseCode == RESPONSE_SUCCESS) { + response.connectionInfo?.ipAddress?.takeIf { it.isNotBlank() } + } else { + null + } + } + .getOrNull() private fun nymeaErrorMessage(code: Int): String = when (code) { NymeaBleConstants.RESPONSE_INVALID_COMMAND -> "Invalid command" diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt index 50a497c5e..32dc2aa53 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt @@ -30,7 +30,10 @@ data class WifiNetwork( /** Result of a WiFi provisioning attempt. */ sealed interface ProvisionResult { - data object Success : ProvisionResult + data class Success( + /** IPv4 address reported by nymea for the active Wi-Fi connection. */ + val ipAddress: String? = null, + ) : ProvisionResult data class Failure(val errorCode: Int, val message: String) : ProvisionResult } diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt index dc9f62f8d..fef0f0e7b 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt @@ -127,6 +127,7 @@ private fun ConnectedWithNetworksPreview() { ConnectedContent( networks = sampleNetworks, provisionStatus = ProvisionStatus.Idle, + ipAddress = null, isProvisioning = false, isScanning = false, onScanNetworks = noOp, @@ -145,6 +146,7 @@ private fun ConnectedEmptyNetworksPreview() { ConnectedContent( networks = emptyList(), provisionStatus = ProvisionStatus.Idle, + ipAddress = null, isProvisioning = false, isScanning = false, onScanNetworks = noOp, @@ -163,6 +165,7 @@ private fun ConnectedScanningPreview() { ConnectedContent( networks = sampleNetworks, provisionStatus = ProvisionStatus.Idle, + ipAddress = null, isProvisioning = false, isScanning = true, onScanNetworks = noOp, @@ -181,6 +184,7 @@ private fun ConnectedProvisioningPreview() { ConnectedContent( networks = sampleNetworks, provisionStatus = ProvisionStatus.Idle, + ipAddress = null, isProvisioning = true, isScanning = false, onScanNetworks = noOp, @@ -199,6 +203,7 @@ private fun ConnectedSuccessPreview() { ConnectedContent( networks = sampleNetworks, provisionStatus = ProvisionStatus.Success, + ipAddress = "10.10.10.61", isProvisioning = false, isScanning = false, onScanNetworks = noOp, @@ -217,6 +222,7 @@ private fun ConnectedFailedPreview() { ConnectedContent( networks = sampleNetworks, provisionStatus = ProvisionStatus.Failed, + ipAddress = null, isProvisioning = false, isScanning = false, onScanNetworks = noOp, @@ -239,6 +245,7 @@ private fun ConnectedLongSsidPreview() { ConnectedContent( networks = edgeCaseNetworks, provisionStatus = ProvisionStatus.Idle, + ipAddress = null, isProvisioning = false, isScanning = false, onScanNetworks = noOp, @@ -257,6 +264,7 @@ private fun ConnectedManyNetworksPreview() { ConnectedContent( networks = manyNetworks, provisionStatus = ProvisionStatus.Idle, + ipAddress = null, isProvisioning = false, isScanning = false, onScanNetworks = noOp, 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 397710fea..559fd8655 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 @@ -14,6 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("TooManyFunctions", "LongMethod") + package org.meshtastic.feature.wifiprovision.ui import androidx.compose.animation.AnimatedVisibility @@ -63,6 +65,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -74,6 +77,7 @@ import androidx.compose.runtime.setValue 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.Role @@ -82,6 +86,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.painterResource @@ -112,15 +117,35 @@ import org.meshtastic.core.resources.wifi_provision_sending_credentials import org.meshtastic.core.resources.wifi_provision_signal_strength import org.meshtastic.core.resources.wifi_provision_ssid_label import org.meshtastic.core.resources.wifi_provision_ssid_placeholder +import org.meshtastic.core.resources.wifi_provision_success_description +import org.meshtastic.core.resources.wifi_provision_success_device_connected +import org.meshtastic.core.resources.wifi_provision_success_done +import org.meshtastic.core.resources.wifi_provision_success_ip_address +import org.meshtastic.core.resources.wifi_provision_success_missing_ip +import org.meshtastic.core.resources.wifi_provision_success_open_ssh +import org.meshtastic.core.resources.wifi_provision_success_open_ssh_fallback +import org.meshtastic.core.resources.wifi_provision_success_password_value +import org.meshtastic.core.resources.wifi_provision_success_setup_description +import org.meshtastic.core.resources.wifi_provision_success_setup_title +import org.meshtastic.core.resources.wifi_provision_success_ssh_command +import org.meshtastic.core.resources.wifi_provision_success_ssh_label +import org.meshtastic.core.resources.wifi_provision_success_ssh_unavailable +import org.meshtastic.core.resources.wifi_provision_success_username +import org.meshtastic.core.resources.wifi_provision_success_username_value import org.meshtastic.core.resources.wifi_provisioning import org.meshtastic.core.ui.component.AutoLinkText +import org.meshtastic.core.ui.component.CopyIconButton import org.meshtastic.core.ui.icon.ArrowBack import org.meshtastic.core.ui.icon.Bluetooth +import org.meshtastic.core.ui.icon.CheckCircle import org.meshtastic.core.ui.icon.Lock import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Serial import org.meshtastic.core.ui.icon.Visibility import org.meshtastic.core.ui.icon.VisibilityOff import org.meshtastic.core.ui.icon.Wifi +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.util.rememberOpenUrl import org.meshtastic.feature.wifiprovision.WifiProvisionError import org.meshtastic.feature.wifiprovision.WifiProvisionUiState import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.Phase @@ -191,6 +216,7 @@ fun WifiProvisionScreen( ConnectedContent( networks = uiState.networks, provisionStatus = uiState.provisionStatus, + ipAddress = uiState.ipAddress, isProvisioning = uiState.phase == Phase.Provisioning, isScanning = uiState.phase == Phase.LoadingNetworks, onScanNetworks = viewModel::scanNetworks, @@ -311,12 +337,18 @@ internal fun ScanningNetworksContent() { internal fun ConnectedContent( networks: List, provisionStatus: ProvisionStatus, + ipAddress: String?, isProvisioning: Boolean, isScanning: Boolean, onScanNetworks: () -> Unit, onProvision: (ssid: String, password: String) -> Unit, onDisconnect: () -> Unit, ) { + if (provisionStatus == ProvisionStatus.Success) { + ProvisionSuccessContent(ipAddress = ipAddress, onDone = onDisconnect) + return + } + var ssid by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } var passwordVisible by rememberSaveable { mutableStateOf(false) } @@ -467,6 +499,121 @@ internal fun ConnectedContent( } } +@Composable +private fun ProvisionSuccessContent(ipAddress: String?, onDone: () -> Unit) { + val openUrl = rememberOpenUrl() + val defaultUsername = stringResource(Res.string.wifi_provision_success_username_value) + val defaultPassword = stringResource(Res.string.wifi_provision_success_password_value) + val resolvedIp = ipAddress ?: stringResource(Res.string.wifi_provision_success_missing_ip) + val sshCommand = + ipAddress?.let { stringResource(Res.string.wifi_provision_success_ssh_command, defaultUsername, it) } + ?: stringResource(Res.string.wifi_provision_success_ssh_unavailable) + val sshUri = ipAddress?.let { "ssh://$defaultUsername@$it" } + + Column( + modifier = + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = MeshtasticIcons.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(72.dp).align(Alignment.CenterHorizontally), + ) + Text( + text = stringResource(Res.string.wifi_provision_success_device_connected), + style = MaterialTheme.typography.headlineMediumEmphasized, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Text( + text = stringResource(Res.string.wifi_provision_success_description), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Card( + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + ProvisionInfoItem( + label = stringResource(Res.string.wifi_provision_success_ip_address), + value = resolvedIp, + copyEnabled = ipAddress != null, + ) + } + + Card( + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) { + Text( + text = stringResource(Res.string.wifi_provision_success_setup_title), + style = MaterialTheme.typography.titleLargeEmphasized, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp), + ) + Text( + text = stringResource(Res.string.wifi_provision_success_setup_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), + ) + ProvisionInfoItem( + label = stringResource(Res.string.wifi_provision_success_username), + value = defaultUsername, + ) + ProvisionInfoItem(label = stringResource(Res.string.password), value = defaultPassword) + ProvisionInfoItem( + label = stringResource(Res.string.wifi_provision_success_ssh_label), + value = sshCommand, + copyEnabled = ipAddress != null, + ) + + FilledTonalButton( + onClick = { sshUri?.let(openUrl) }, + enabled = sshUri != null, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 12.dp), + ) { + Icon( + imageVector = MeshtasticIcons.Serial, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.wifi_provision_success_open_ssh)) + } + Text( + text = stringResource(Res.string.wifi_provision_success_open_ssh_fallback), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(Res.string.wifi_provision_success_done)) + } + } +} + +@Composable +private fun ProvisionInfoItem(label: String, value: String, copyEnabled: Boolean = true) { + ListItem( + overlineContent = { Text(text = label, style = MaterialTheme.typography.labelLarge) }, + headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyLargeEmphasized) }, + trailingContent = { + if (copyEnabled) { + CopyIconButton(valueToCopy = value) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + @Composable internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () -> Unit) { val containerColor = @@ -548,3 +695,9 @@ private fun CenteredStatusContent(content: @Composable () -> Unit) { content() } } + +@PreviewLightDark +@Composable +private fun ProvisionSuccessContentPreview() { + AppTheme { Surface { ProvisionSuccessContent(ipAddress = "192.168.1.100", onDone = {}) } } +} diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt index 0ee5bb0ec..eb658c0ff 100644 --- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt @@ -90,6 +90,7 @@ class WifiProvisionViewModelTest { assertTrue(state.networks.isEmpty()) assertNull(state.error) assertNull(state.deviceName) + assertNull(state.ipAddress) assertEquals(ProvisionStatus.Idle, state.provisionStatus) } @@ -233,12 +234,13 @@ class WifiProvisionViewModelTest { advanceUntilIdle() // Now provision — enqueue success response - emitNymeaResponse("""{"c":1,"r":0}""") + emitNymeaResponse("""{"c":1,"r":0,"p":{"i":"10.10.10.61"}}""") viewModel.provisionWifi("Net", "password123") advanceUntilIdle() val state = viewModel.uiState.value assertEquals(Phase.Connected, state.phase) + assertEquals("10.10.10.61", state.ipAddress) assertEquals(ProvisionStatus.Success, state.provisionStatus) } @@ -305,6 +307,7 @@ class WifiProvisionViewModelTest { assertEquals(Phase.Idle, state.phase) assertTrue(state.networks.isEmpty()) assertNull(state.deviceName) + assertNull(state.ipAddress) assertEquals(ProvisionStatus.Idle, state.provisionStatus) } diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt index 2913ce55e..8f2151fb8 100644 --- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt @@ -82,6 +82,7 @@ class NymeaProtocolTest { val response = NymeaJson.decodeFromString("""{"c":4,"r":0}""") assertEquals(4, response.command) assertEquals(0, response.responseCode) + assertEquals(null, response.connectionInfo) } @Test @@ -91,6 +92,14 @@ class NymeaProtocolTest { assertEquals(3, response.responseCode) } + @Test + fun `response deserializes connection info payload`() { + val response = NymeaJson.decodeFromString("""{"c":5,"r":0,"p":{"i":"10.10.10.61"}}""") + assertEquals(5, response.command) + assertEquals(0, response.responseCode) + assertEquals("10.10.10.61", response.connectionInfo?.ipAddress) + } + @Test fun `response ignores unknown keys`() { val response = NymeaJson.decodeFromString("""{"c":0,"r":0,"extra":"field"}""") diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt index 666d81e48..e356daa26 100644 --- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt @@ -221,10 +221,25 @@ class NymeaWifiServiceTest { val (service, scanner) = createService(connection = connection) connectService(service, scanner) - emitResponse(connection, """{"c":1,"r":0}""") + emitResponse(connection, """{"c":1,"r":0,"p":{"i":"10.10.10.61"}}""") val result = service.provision("MyNet", "password") - assertIs(result) + val success = assertIs(result) + assertEquals("10.10.10.61", success.ipAddress) + } + + @Test + fun `provision falls back to GetConnection for IP when connect response has no payload`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + emitResponse(connection, """{"c":1,"r":0}""") + emitResponse(connection, """{"c":5,"r":0,"p":{"i":"10.10.10.62"}}""") + val result = service.provision("MyNet", "password") + + val success = assertIs(result) + assertEquals("10.10.10.62", success.ipAddress) } @Test @@ -247,7 +262,7 @@ class NymeaWifiServiceTest { val (service, scanner) = createService(connection = connection) connectService(service, scanner) - emitResponse(connection, """{"c":1,"r":0}""") + emitResponse(connection, """{"c":1,"r":0,"p":{"i":"10.10.10.61"}}""") service.provision("Net", "pass", hidden = false) val writes = @@ -266,7 +281,7 @@ class NymeaWifiServiceTest { val (service, scanner) = createService(connection = connection) connectService(service, scanner) - emitResponse(connection, """{"c":2,"r":0}""") + emitResponse(connection, """{"c":2,"r":0,"p":{"i":"10.10.10.61"}}""") service.provision("HiddenNet", "pass", hidden = true) val writes = From bc66b99e3dc46846965aa654ce458c65f0967e88 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:30:47 -0500 Subject: [PATCH 53/65] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5227) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-be/strings.xml | 1 + .../composeResources/values-bg/strings.xml | 3 + .../composeResources/values-cs/strings.xml | 2 + .../composeResources/values-de/strings.xml | 3 + .../composeResources/values-el/strings.xml | 1 + .../composeResources/values-es/strings.xml | 3 + .../composeResources/values-et/strings.xml | 2 + .../composeResources/values-fi/strings.xml | 39 +++++ .../composeResources/values-fr/strings.xml | 3 + .../composeResources/values-hu/strings.xml | 1 + .../composeResources/values-it/strings.xml | 3 + .../composeResources/values-ja/strings.xml | 1 + .../composeResources/values-ko/strings.xml | 2 + .../composeResources/values-nl/strings.xml | 1 + .../composeResources/values-pl/strings.xml | 3 + .../values-pt-rBR/strings.xml | 2 + .../composeResources/values-pt/strings.xml | 1 + .../composeResources/values-ro/strings.xml | 2 + .../composeResources/values-ru/strings.xml | 3 + .../composeResources/values-sk/strings.xml | 1 + .../composeResources/values-sr/strings.xml | 1 + .../composeResources/values-srp/strings.xml | 1 + .../composeResources/values-sv/strings.xml | 3 + .../composeResources/values-tr/strings.xml | 2 + .../composeResources/values-uk/strings.xml | 155 ++++++++++++++++++ .../values-zh-rCN/strings.xml | 3 + .../values-zh-rTW/strings.xml | 3 + 27 files changed, 245 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml index 21bf82cf3..3d20243fc 100644 --- a/core/resources/src/commonMain/composeResources/values-be/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml @@ -222,6 +222,7 @@ Чырвоны Сіні Зялёны + Імя карыстальніка Meshtastic Фільтраваць diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index b89322e0f..e97d225cf 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -980,6 +980,9 @@ Въведете или изберете мрежа WiFi е конфигуриран успешно! Прилагането на конфигурацията за WiFi не е успешно + IP адрес + Потребителско име + Готово Изход Meshtastic Филтър diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index b3e88dcf1..713f7383e 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -970,6 +970,8 @@ Poznámka Připojit Hotovo + Uživatelské jméno + 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 592633688..6bafa634e 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -1228,6 +1228,9 @@ Netzwerk eingeben oder auswählen WLAN erfolgreich konfiguriert! WLAN Konfiguration konnte nicht angewendet werden + IP Adresse + Benutzername + Fertig Meshtastic Desktop Meshtastic anzeigen Beenden diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml index 8386ac2ea..caf6e271b 100644 --- a/core/resources/src/commonMain/composeResources/values-el/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml @@ -201,5 +201,6 @@ Κόκκινο Μπλε Πράσινο + Όνομα χρήστη Φίλτρο diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index b9dd520c3..a7caf988b 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -838,6 +838,9 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Verde Conectar Hecho + Dirección IP + Usuario + 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 ff520d4c8..ecd9a422d 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -1226,6 +1226,8 @@ Sisestage või valige võrk WiFi edukalt seadistatud! WiFi sätete rakendamine ebaõnnestus + Kasutajatunnus + Valmis Meshtastic töölaud Näita Meshtastic Sule diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index e87b9f452..32b301332 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -114,6 +114,10 @@ Short Range - Turbo Short Range - Fast Short Range - Slow + Lite - Fast + Lite - Slow + Narrow - Fast + Narrow - Slow WiFi:n käyttöön ottaminen poistaa Bluetooth-yhteyden sovellukseen. Ethernetin ottaminen käyttöön poistaa Bluetooth-yhteyden sovellukseen. TCP-laiteyhteydet eivät ole käytettävissä Applen laitteilla. Ota käyttöön pakettien lähettäminen UDP:n kautta paikallisverkossa. @@ -177,6 +181,7 @@ USB-laitteita ei löytynyt USB BLE + TCP USB Esittelytila Yhdistetty radioon, mutta se on lepotilassa @@ -793,6 +798,13 @@ Isäntälaitteen mittausarvot Pax mittarit Metatiedot + Päivitä metatiedot + Yhdistä & ylläpidä + Muodostetaan etäyhteyttä… + Istunto aktiivinen + Päivitys vaaditaan + Yhdistä radioon hallitaksesi etälaitteita. + Yhteyttä radioon ei saatu — yritä uudelleen tai siirry lähemmäksi. Yritä uudelleen Toiminnot Laiteohjelmisto @@ -904,10 +916,24 @@ Laiteohjelmistoversio Äskettäin havaitut verkkolaitteet Löydetyt verkkolaitteet + Etsi verkkolaitteita Etsitään… + Etsi Bluetooth-laitteita Etsitään… Saatavilla olevat Bluetooth-laitteet + Lisää laite manuaalisesti… + Laitteita ei löytynyt + Bluetooth-laitteita ei löytynyt + Varmista, että olet riittävän lähellä laitetta. + Verkkolaitteita ei löytynyt + Varmista, että laite ja sinä olette samassa verkossa. + USB-laitteita ei löytynyt + Yhdistä laite sarjaliitännän tai USB:n kautta. Ei laitetta kytkettynä + Yhdistä laitteeseen etsiäksesi lähellä olevia radioita. + Etsitään radioita + Lähistön radiot ilmestyvät tähän sitä mukaa kun niitä havaitaan. + Määritä yhteys Näin pääset alkuun Tervetuloa Pysy yhteydessä kaikkialla @@ -1228,6 +1254,19 @@ Syötä tai valitse verkko WiFi määritetty onnistuneesti! WiFi-asetusten käyttöönotto epäonnistui + Laite yhdistetty + mPWRD-OS-laitteesi on yhdistetty Wi-Fi-verkkoon. + IP-osoite + Suorita laitteen määritys loppuun + Kirjaudu SSH-yhteydellä muuttaaksesi oletuskäyttäjätunnuksen ja salasanan. + Käyttäjänimi + SSH-komento + ssh %1$s@%2$s + SSH-komento on käytettävissä IP-osoitteen määrityksen jälkeen. + Avaa SSH-asiakasohjelma + Jos sovellus ei aukea, kopioi SSH-komento ja liitä se SSH-asiakasohjelmaasi. + IP-osoitetta ei ole saatavilla + Valmis Meshtastic työpöytä Näytä Meshtastic Lopeta diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index e442b0f98..0ebeabf1e 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -1214,6 +1214,9 @@ Saisir ou sélectionnez un réseau WiFi configuré avec succès ! Impossible d'appliquer la configuration WiFi + Adresse IP + Nom d'utilisateur + Terminé Meshtastic application de bureau Afficher Meshtastic Quitter diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml index 33b795a7f..731977f70 100644 --- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml @@ -849,6 +849,7 @@ Kék Zöld Csatlakozás + Felhasználónév 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 205ab60f0..69b24868c 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -963,6 +963,9 @@ Note Connetti Fatto + Indirizzo IP + Username + 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 64aa0fe05..1fa64b63e 100644 --- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml @@ -651,6 +651,7 @@ トラフィック管理設定 モジュール有効 接続 + ユーザー名 Meshtastic 絞り込み diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml index 2e193773c..2e3cabb5b 100644 --- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml @@ -539,6 +539,8 @@ 파랑 초록 연결 + IP 주소 + 사용자명 Meshtastic 필터 diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml index b6972b6ec..bfb40ff30 100644 --- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml @@ -415,5 +415,6 @@ Blauw Groen Verbinding maken + Gebruikersnaam Filter diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index fb141af2d..dcfb4176e 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -751,6 +751,9 @@ Moduł Włączony Połącz Wykonano + Adres IP + Nazwa użytkownika + 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 ac97b091c..45754ca21 100644 --- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -664,6 +664,8 @@ Azul Verde Concluído + Nome de usuário + 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 a00bce554..ee31267be 100644 --- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml @@ -514,6 +514,7 @@ Azul Verde Ligar + Utilizador 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 256710470..f7cdbb099 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -1171,6 +1171,8 @@ Introdu sau selecteaza o retea WiFi configurat cu succes! Nu s-a reușit aplicarea configurației Wi-Fi + Nume de utilizator + Gata 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 e975c5e37..91aadb6a1 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -1243,6 +1243,9 @@ Введите или выберите сеть Wi-Fi успешно настроен! Не удалось применить настройку Wi-Fi + IP-адрес + Имя пользователя + Готово Meshtastic Desktop Показать Meshtastic Выход diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml index 6beec1a74..c8006648e 100644 --- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml @@ -426,6 +426,7 @@ Červená Modrá Zelená + Používateľské meno Meshtastic Filter diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index 39f560ae6..a3f0cbcde 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -431,5 +431,6 @@ Блутут Напајано + Корисничко име Filter diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index f4a4c211b..d9bfa1f5f 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -431,5 +431,6 @@ Блутут Напајано + Корисничко име Филтер diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index 6fef6870a..ee79a4fea 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -951,6 +951,9 @@ Modul aktiverad Anslut Klart + IP-adress + Användarnamn + 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 75a9e3a5d..dc5e11cc5 100644 --- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml @@ -545,6 +545,8 @@ Mavi Yeşil Bağlan + IP Adresi + Kullanıcı adı 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 c10440981..e92c80c8f 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -31,25 +31,38 @@ A-Z Канал Відстань + Кількість ретрансляцій + Востаннє в мережі через MQTT через MQTT через UDP через API + Внутрішній через Обране Показати лише ігноровані вузли + Виключити MQTT + Невпізнанно Очікування на підтвердження У черзі для надсилання + Доставлено в мережу + Невідомий Маршрутизація через SF++ ланцюжок… Підтверджений на ланцюжку SF++ Підтверджено Маршрут відсутній Отримано негативне підтвердження Таймаут + Інтерфейс відсутній + Досягнуто ліміт спроб Канал відсутній Пакет завеликий Немає відповіді Невірний запит + Перевищено регіональний ліміт ефіру + Не авторизований + Помилка зашифрованої передачі Невідомий відкритий ключ + Помилка ключа сеансу Несанкціонований відкритий ключ Помилка надсилання PKI, відсутній публічний ключ Застосунок з'єднано або автономний режим обміну повідомленнями. @@ -57,20 +70,38 @@ Розглядає пакети від або до улюблених вузлів так само як ROUTER_LATE, а всі інші пакети як CLIENT. Вузол інфраструктури для розширення покриття мережею повторними повідомленнями. Видимий у списку вузлів. Комбінація ROUTER і CLIENT. Не для мобільних пристроїв. + Інфраструктурний вузол для покриття мережі. Пересилає повідомлення з мінімальним навантаженням. Не показується в списку вузлів. + Пріоритетна трансляція GPS-координат. Пріоритетна передача пакетів телеметрії. Оптимізовано для з'єднання з системою ATAK, зменшує рутинні радіо трансляції. Пристрій, який передає лише у разі потреби для економії енергії або скритності. + Регулярно транслює координати в основний канал для полегшення пошуку пристрою. Увімкнути автоматичну передачу TAK PLI та зменшити кількість звичайних трансляцій. + Інфраструктурний вузол, що завжди ретранслює пакети один раз після всіх інших режимів, забезпечуючи додаткове покриття для локальних груп. Показується у списку вузлів. + Ретранслювати будь-яке виявлене повідомлення, якщо воно з нашого приватного каналу або з іншої мережі з такими ж параметрами LoRa. Така сама поведінка, як і ВСІ (ALL), але пропускає декодування і просто пересилає їх. Доступно лише в ролі Repeater. Установка цієї опції на будь-які інші ролі призведе до поведінки ВСІ. + Ігнорує чужі mesh-мережі та пакети, які неможливо розшифрувати. Повторно передає повідомлення лише для власних каналів пристрою. Ігнорує отримані повідомлення від чужих мереж, як-от LOCAL ONLY, але робить крок далі, також ігноруючи повідомлення від вузлів, яких немає в списку відомих вузлів. Дозволяється лише для таких ролей, як SENSOR, TRACKER та TAK_TRACKER, і гальмуватиме всі перенаправлення, на відміну від ролі CLIENT_MUTE. + Ігнорує пакети з нестандартними номерами портів: TAK, RangeTest, PaxCounter, тощо. Ретранслює лише стандартні типи: дані вузла, текст, координати, телеметрію та маршрутизацію. + Розпізнавати подвійне постукування по корпусу як натискання кнопки користувача. + Надсилати геопозицію в основний канал при потрійному натисканні кнопки. + Керування миготливим світлодіодом пристрою. На більшості плат можна керувати одним із 4 доступних світлодіодів; індикатори зарядки та GPS не підтримують керування. Часовий пояс для дати на екрані та журналі пристрою. Використовувати часовий пояс телефону + Дозволяє трансляцію NeighborInfo через LoRa. Дані про сусідів зазвичай надсилаються на MQTT та додаток, але ця опція вмикає їх передачу в ефір. Не працює на каналах зі стандартним ключем. + Час роботи екрана після натискання кнопки або отримання повідомлень. + Автоматично перемикає сторінки на екрані за принципом каруселі з заданим інтервалом. + Покажчик компаса за межами кола на екрані завжди вказуватиме на північ. Перевернути екран по вертикалі. Одиниці, що показуються на екрані пристрою. + Ручне керування виявленням OLED-екрану. + Виділяти текст заголовка на екрані жирним шрифтом. Вимагає наявність акселерометра на вашому пристрої. Регіон, де ви будете використовувати радіо. Доступні пресети для модема, за замовчуванням — Long Fast. + Встановлює максимальну кількість стрибків, стандартно — 3. Збільшення цього значення підвищує завантаженість ефіру, тому його слід використовувати обережно. Повідомлення з 0 стрибків не отримують підтвердження ACK. + Налаштування частотного слота. При значенні 0 частота визначається назвою вашого основного каналу. Якщо у вас приватний основний канал, але ви хочете чути публічні повідомлення на додаткових, встановіть тут номер стандартного публічного слота. Дуже велика дальність - Повільно Велика дальність - Швидко Long Range - Turbo @@ -81,19 +112,35 @@ Мала дальність - Турбо Мала дальність - Повільно Мала дальність - Повільно + Легкий - Швидкий + Легкий - Повільний + Вузький - Швидкий + Вузький - Повільний Увімкнення Wi-Fi вимкне Bluetooth-з'єднання до програми. Увімкнення Ethernet вимкне Bluetooth-з'єднання до програми. З'єднання з вузлами через TCP недоступні на пристроях Apple. Увімкнути трансляцію пакетів через UDP через локальну мережу. + Максимальний інтервал, після якого вузол обов'язково надсилає свої координати. + Мінімальна дистанція в метрах, після подолання якої вузол оновить геопозицію. Як часто слід намагатися отримати позицію GPS (<10 сек тримає GPS постійно увімкненим). + Додаткові поля для пакетів позиції. Чим більше полів вибрано, тим більшим буде розмір повідомлення, що збільшує час передачі в ефірі та ризик втрати пакетів. + Режим глибокого сну для всіх систем. У ролях «Tracker» та «Sensor» також вимикається радіомодуль. Не вмикайте це налаштування, якщо вам потрібен постійний зв'язок із додатком або якщо пристрій не має фізичної кнопки для пробудження. + Генерується на основі вашого приватного ключа та надсилається іншим вузлам мережі, щоб вони могли обчислити спільний секретний ключ. Використовується для створення спільного ключа з віддаленим пристроєм. Публічний ключ уповноважений надсилати повідомлення адміністратора на цей вузол. + Пристрій керується адміністратором мережі, доступ до налаштувань для користувача обмежено. + Послідовна консоль через Stream API. + Виведення налагоджувальних логів у реальному часі через Serial; перегляд та експорт логів із прихованими координатами через Bluetooth. Пакет позиції Інтервал трансляції + Інтелектуальне передавання позиції + Інтелектуальний інтервал + Інтелектуальна дистанція GPS пристрій Фіксована позиція Висота Інтервал опитування GPS + Розширений пристрій GPS GPIO отримання GPS GPIO передачі GPS GPS EN GPIO @@ -125,13 +172,22 @@ IP Ethernet: Під’єднання Не підключено + Не вибраний пристрій + Невідомий пристрій + Не знайдений мережевий пристрій + USB BLE + TCP + USB + Демо режим Підключено до радіомодуля, але він в режимі сну Потрібне оновлення програми Ви повинні оновити цю програму в App Store (або Github). Він занадто старий, щоб спілкуватися з цією прошивкою радіо. Будь ласка, прочитайте нашу документацію у вказаній темі. Відсутнє (вимкнуте) Сервісні сповіщення Подяки + Бібліотеки з відкритим вихідним кодом + Meshtastic побудований на бібліотеках з відкритим вихідним кодом. Натисніть на будь-яку бібліотеку, щоб побачити її ліцензію. URL-адреса цього каналу недійсна та не може бути використана Панель налагодження Експортувати журнали @@ -156,12 +212,25 @@ Попередій збіг Очистити пошук Додати фільтр + Фільтр включено Очистити всі фільтри Додати свій фільтр Готові фільтри + Сховище для логування Очистити журнал + Відповідає будь-якому/всім + Відповідає всім/будь-якому Очистити + Пошук смайлів... Канал + Заголовок + Підвал + Крапка + Текст + Шкала + Градієнт + Це кастомна комбінація + З декількома лініями та стилями Статус доставки повідомлень Нові повідомлення нище Сповіщення особистих повідомлень @@ -180,11 +249,17 @@ Відновити налаштування за замовчуванням Застосувати Тема + Контраст Світла Темна Системна Оберіть тему + Рівень контрасту + Стандартний + Середній + Високий Укажіть розташування для мережі + Компактне декодування кирилиці Видалити повідомлення? Видалити %1$s повідомлення? @@ -196,6 +271,7 @@ Видалити для мене Вибрати Вибрати все + Закрити вибір Видалити вибране Завантажити регіон Ім'я @@ -226,6 +302,7 @@ Пряме повідомлення Очищення бази вузлів Доставку підтверджено + Ваш пристрій буде від'єднано та перезавантажено, поки будуть застосовані налаштування. Помилка Невідома помилка Ігнорувати @@ -235,6 +312,7 @@ Оберіть регіон завантаження Час завантаження фрагментів: Почати завантаження + Обмін місцеположенням Закрити Налаштування пристрою Налаштування модуля @@ -265,48 +343,79 @@ 1 тиждень Завжди Наразі: + Завжди в безшумному режимі + Не в безшумному режимі Замінити Сканувати QR-код Wi-Fi + Некоректний формат QR-коду з даними WiFi Перейти назад Батарея Завантаженість каналу + Завантаженість ефіру + Вологість Температура ґрунту Вологість ґрунту Журнали подій + Кількість стрибків повідомлення Інформація + Загальна завантаженість поточного каналу, включно з коректною передачею TX, прийом RX та помилкові пакети (шум). + Відсоток ефірного часу, використаного для передачі за останню годину. IAQ Значення ключа шифрування Спільний ключ + Доступні лише повідомлення в каналах. Прямі повідомлення потребують підтримки Pki, яка з'явилася у версіях прошивки 2.5+ та вище. Шифрування з відкритим ключем + Для шифрування особистих повідомлень використовується нова система публічних ключів. Не збігаються відкритий ключ + Публічний ключ не збігається із записаним ключем. Ви можете видалити вузол і дозволити йому обмінятися ключами знову, але це може свідчити про проблему з безпекою. Зв’яжіться з користувачем через інший довірений канал, щоб з’ясувати, чи була зміна ключа наслідком скидання до заводських налаштувань або іншої навмисної дії. Дані користувача Сповіщення про нові вузли SNR RSSI + (Якість повітря в приміщенні) відносна шкала IAQ за вимірюваннями Bosch BME680. Діапазон значень: 0–500. Показники пристрою Місцезнаходження + Останнє оновлення позиції Показники довкілля Адміністрування Віддалене керування Поганий Задовільний Хороший + Жоден Поділитися з… Сигнал Якість сигналу Маршрут + Прямий + Вихідний маршрут + Відповідь Traceroute тим самим маршрутом + Неможливо показати мапу трасування, оскільки початковий або кінцевий вузол не мають даних про позицію. Переглянути на мапі + У цьому маршруті поки що немає вузлів із координатами для показу. Показується %1$d/%2$d вузлів Тривалість: %1$s сек Маршрут у напрямку призначення:\n\n Зворотний маршрут до нас:\n\n + Прямі стрибки + Зворотні стрибки + Тривалість кругового маршруту + Немає відповіді + Навантаження 5 хвилин + Навантаження 15 хвилин + Доступно системної пам'яті в байтах 24Г Макс + Мінімум + Розгорнути графік + Згорнути графік + Невідомий вік Копіювати + Символ звукового сповіщення! Критичне сповіщення! Обране Додати до обраних @@ -317,6 +426,12 @@ Канал 1 Канал 2 Канал 3 + Канал 4 + Канал 5 + Канал 6 + Канал 7 + Канал 8 + Поточний Напруга Ви впевнені? ]]> @@ -364,6 +479,7 @@ Червоний Зелений Синій + Енкодер #1 активований Надіслати дзвіночок Повідомлення Макс. кількість баз даних, що зберігаються на цьому телефоні @@ -376,20 +492,40 @@ Роль пристрою GPIO кнопки GPIO гудка + Інтервал розсилки даних про вузол + Подвійний дотик як натискання кнопки + Швидка перевірка зв'язку потрійним натисканням Часовий пояс + Частота мигання світлодіоду Дисплей пристрою + Екран включено для + Інтервал гортання каруселі + Компас північ зверху Перевернути екран Одиниці виміру Тип OLED Режим екрану Завжди вказувати на північ + Жирні заголовки + Прокидання дотиком або рухом Орієнтація компаса Налаштування зовнішніх сповіщень Зовнішні сповіщення увімкнено Сповіщення про отримання повідомлень + Мигання світлодіоду при повідомленнях тривоги + Звук зумеру при повідомленнях тривоги + Вібрація при повідомленнях тривоги + Сповіщення при отриманні сигналу тривоги/дзвінка + LED-індикатор сигналу тривоги + Зумер сигналу тривоги + Вібрація сигналу тривоги Вихідний LED (GPIO) + Активний високий рівень світлодіода Вихідний гудок (GPIO) + Використовувати зумер із ШІМ-керуванням + Вихід вібросигналу (GPIO) Тривалість виводу (мілісекунд) + Інтервал нагадувань (секунди) Мелодія Використовувати I2S як гудок LoRa @@ -397,14 +533,22 @@ Розширені Використовувати пресет Пресети + Ширина смуги пропускання Швидкість кодування Регіон + Кількість стрибків + Передача активована Потужність передачі Слот частоти + Ігнорувати обмеження завантаженості каналу Ігнорувати вхідні + Підсилення передачі Перевизначити частоту + Вентилятор вимкнений Ігнорувати MQTT + MQTT: Готово Налаштування MQTT + Неактивний Відключено Під’єднано Перевірка зʼєднання @@ -433,6 +577,7 @@ IP-адреса Шлюз DNS + Статус повідомлення RSSI поріг WiFi (за замовчуванням -80) RSSI поріг BLE (за замовчуванням -80) Широта @@ -552,6 +697,8 @@ Meshtastic Невідомий канал Попередження + Невідомий + Цей пристрій керований та може бути змінений тільки віддаленим адміністратором. Розширені Очистити базу даних вузлів Очистити вузли, які не були онлайн більше %1$d дні(в) @@ -573,6 +720,7 @@ Показники PAX PAX Немає доступних показників PAX. + Bluetooth пристрої Під'єднаний пристрій Переглянути реліз Завантажити @@ -582,6 +730,10 @@ Підтримується спільнотою Meshtastic Тип прошивки Виявлені мережеві пристрої + Сканування… + Доступні Bluetooth пристрої + Додати пристрій вручну… + Пристрої не знайдено Немає під'єднаних пристроїв З чого почати Ласкаво просимо до @@ -730,6 +882,9 @@ Зелений Під’єднатися Готово + Адреса IP + Ім'я користувача + Готово 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 fc5a6d8c2..03a202ac8 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -1118,6 +1118,9 @@ 备注 连接 完成 + IP 地址 + 用户名称 + 完成 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 e99300c67..4140e5419 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -1218,6 +1218,9 @@ 手動輸入或選擇一個網路 Wi-Fi 已設定完成! 無法套用 Wi-Fi 設定 + IP 位址 + 使用者名稱 + 完成 Meshtastic Desktop 顯示 Meshtastic 離開 From 019c65ad8b09221f35aae3a65f928ea6c4bfbfc5 Mon Sep 17 00:00:00 2001 From: Nick <31907977+zt64@users.noreply.github.com> Date: Thu, 23 Apr 2026 05:47:34 -0400 Subject: [PATCH 54/65] fix(ui): make footer buttons expand downwards (#5226) --- .../feature/settings/radio/component/RadioConfigScreenList.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt index d10c81f85..d1301002a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -85,6 +86,7 @@ fun > RadioConfigScreenList( item { AnimatedVisibility( + modifier = Modifier.fillMaxWidth(), visible = showFooterButtons, enter = fadeIn() + expandIn(), exit = fadeOut() + shrinkOut(), From 91a61a36caa6f12d4829ea1daa6fb72ec57af95c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 05:10:10 -0500 Subject: [PATCH 55/65] chore(deps): update kotlin to v2.3.21 (#5228) 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 32790eb40..9a524e77d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ koin = "4.2.1" koin-plugin = "1.0.0-RC2" # Kotlin -kotlin = "2.3.21-RC2" +kotlin = "2.3.21" kotlinx-coroutines-android = "1.10.2" kotlinx-datetime = "0.7.1" kotlinx-serialization = "1.11.0" From 815882d880e4988033fa92787051c3a64f50b252 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 05:25:43 -0500 Subject: [PATCH 56/65] feat(messaging): add entry points for filter settings (#5229) --- .../meshtastic/feature/messaging/Message.kt | 6 +++++- .../component/MessageScreenComponents.kt | 21 +++++++++++++++++++ .../navigation/ContactsNavigation.kt | 2 ++ .../feature/settings/SettingsScreen.kt | 11 ++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt index 8cc621e1c..5a37c085d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -99,9 +99,11 @@ private const val MAX_LINES = 3 * @param message An optional message to pre-fill in the input field. * @param viewModel The [MessageViewModel] instance for handling business logic and state. * @param navigateToNodeDetails Callback to navigate to a node's detail screen. + * @param navigateToQuickChatOptions Callback to navigate to the quick chat options screen. + * @param navigateToFilterSettings Callback to navigate to the message filter settings screen. * @param onNavigateBack Callback to navigate back from this screen. */ -@Suppress("LongMethod", "CyclomaticComplexMethod") // Due to multiple states and event handling +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun MessageScreen( contactKey: String, @@ -109,6 +111,7 @@ fun MessageScreen( viewModel: MessageViewModel, navigateToNodeDetails: (Int) -> Unit, navigateToQuickChatOptions: () -> Unit, + navigateToFilterSettings: () -> Unit, onNavigateBack: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -321,6 +324,7 @@ fun MessageScreen( filteredCount = filteredCount, showFiltered = showFiltered, onToggleShowFiltered = viewModel::toggleShowFiltered, + onNavigateToFilterSettings = navigateToFilterSettings, ) } }, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt index 6416337df..b5504618d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt @@ -75,6 +75,7 @@ import org.meshtastic.core.resources.delete_messages_title import org.meshtastic.core.resources.filter_disable_for_contact import org.meshtastic.core.resources.filter_enable_for_contact import org.meshtastic.core.resources.filter_hide_count +import org.meshtastic.core.resources.filter_settings import org.meshtastic.core.resources.filter_show_count import org.meshtastic.core.resources.navigate_back import org.meshtastic.core.resources.new_messages_below @@ -103,6 +104,7 @@ import org.meshtastic.core.ui.icon.More import org.meshtastic.core.ui.icon.Muted import org.meshtastic.core.ui.icon.Reply import org.meshtastic.core.ui.icon.SelectAll +import org.meshtastic.core.ui.icon.Settings import org.meshtastic.core.ui.icon.Unmuted import org.meshtastic.core.ui.icon.Visibility import org.meshtastic.core.ui.icon.VisibilityOff @@ -297,6 +299,7 @@ fun MessageTopBar( filteredCount: Int = 0, showFiltered: Boolean = false, onToggleShowFiltered: () -> Unit = {}, + onNavigateToFilterSettings: () -> Unit = {}, ) = TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { @@ -328,6 +331,7 @@ fun MessageTopBar( filteredCount = filteredCount, showFiltered = showFiltered, onToggleShowFiltered = onToggleShowFiltered, + onNavigateToFilterSettings = onNavigateToFilterSettings, ) }, ) @@ -344,6 +348,7 @@ private fun MessageTopBarActions( filteredCount: Int, showFiltered: Boolean, onToggleShowFiltered: () -> Unit, + onNavigateToFilterSettings: () -> Unit, ) { if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) { NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey) @@ -364,6 +369,7 @@ private fun MessageTopBarActions( filteredCount = filteredCount, showFiltered = showFiltered, onToggleShowFiltered = onToggleShowFiltered, + onNavigateToFilterSettings = onNavigateToFilterSettings, ) } } @@ -380,6 +386,7 @@ private fun OverFlowMenu( filteredCount: Int, showFiltered: Boolean, onToggleShowFiltered: () -> Unit, + onNavigateToFilterSettings: () -> Unit, ) { if (expanded) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { @@ -389,6 +396,7 @@ private fun OverFlowMenu( FilteredMessagesMenuItem(showFiltered, filteredCount, onDismiss, onToggleShowFiltered) } FilterToggleMenuItem(filteringDisabled, onDismiss, onToggleFilteringDisabled) + FilterSettingsMenuItem(onDismiss, onNavigateToFilterSettings) } } } @@ -463,6 +471,19 @@ private fun FilterToggleMenuItem(filteringDisabled: Boolean, onDismiss: () -> Un ) } +@Composable +private fun FilterSettingsMenuItem(onDismiss: () -> Unit, onNavigate: () -> Unit) { + val title = stringResource(Res.string.filter_settings) + DropdownMenuItem( + text = { Text(title) }, + onClick = { + onDismiss() + onNavigate() + }, + leadingIcon = { Icon(imageVector = MeshtasticIcons.Settings, contentDescription = title) }, + ) +} + // endregion // region ── QuickChatRow ── diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index 62b57d3a8..75e18c46b 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.ContactsRoute import org.meshtastic.core.navigation.NodesRoute +import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.replaceLast import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.feature.messaging.QuickChatScreen @@ -65,6 +66,7 @@ fun EntryProviderScope.contactsGraph( navigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, navigateToQuickChatOptions = dropUnlessResumed { backStack.add(org.meshtastic.core.navigation.ContactsRoute.QuickChat) }, + navigateToFilterSettings = dropUnlessResumed { backStack.add(SettingsRoute.FilterSettings) }, onNavigateBack = dropUnlessResumed { backStack.removeLastOrNull() }, ) } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 53e2b7323..83ee18c67 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -46,6 +46,7 @@ import org.meshtastic.core.navigation.WifiProvisionRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bottom_nav_settings import org.meshtastic.core.resources.export_configuration +import org.meshtastic.core.resources.filter_settings import org.meshtastic.core.resources.import_configuration import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.remotely_administrating @@ -53,6 +54,7 @@ import org.meshtastic.core.resources.wifi_devices import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.FilterList import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.settings.component.AppInfoSection @@ -249,6 +251,15 @@ fun SettingsScreen( } } + ExpressiveSection(title = stringResource(Res.string.filter_settings)) { + ListItem( + text = stringResource(Res.string.filter_settings), + leadingIcon = MeshtasticIcons.FilterList, + ) { + onNavigate(SettingsRoute.FilterSettings) + } + } + PersistenceSection( cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value, onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) }, From 2c001c47d1ea51c969adc46996852e62acb1174a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 06:22:31 -0500 Subject: [PATCH 57/65] fix(desktop): unbreak release builds (CMP beta03 + pwsh -P quoting) (#5230) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 5 ++++- .../meshtastic/app/ui/NavigationAssemblyTest.kt | 2 +- .../org/meshtastic/buildlogic/AndroidCompose.kt | 12 +++++++----- .../core/barcode/BarcodeScannerTest.kt | 2 +- gradle/libs.versions.toml | 17 +++++++++-------- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40d8e40f3..1d7f8e012 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -285,7 +285,10 @@ jobs: env: ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} APPIMAGE_EXTRACT_AND_RUN: 1 - run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon + # Quote the -P flag: PowerShell on Windows interprets the dot in + # `-PaboutLibraries.release=true` as member access on `-PaboutLibraries`, + # splitting the token and feeding `.release=true` to Gradle as a task name. + run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS '-PaboutLibraries.release=true' --no-daemon - name: List Desktop Binaries if: runner.os == 'Linux' diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index de6062d33..1fd4b39ce 100644 --- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -17,7 +17,7 @@ package org.meshtastic.app.ui import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt index b438fe6c6..d3cfceb2a 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt @@ -32,10 +32,12 @@ internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { exclude(mapOf("group" to "androidx.compose", "module" to "compose-bom")) } - // CMP publishes these core AndroidX groups at the CMP version tag. - // Material, Material3, and Adaptive follow separate AndroidX version numbers - // and must NOT be included here (see CMP release notes for the mapping table). - val cmpVersion = libs.version("compose-multiplatform") + // CMP publishes these core AndroidX groups at an AndroidX version tag that + // tracks (but does not equal) the CMP version. The exact mapping lives in + // the CMP release notes; we mirror it via the `androidx-compose-bom-aligned` + // version ref in libs.versions.toml. Material, Material3, and Adaptive follow + // separate AndroidX version numbers and must NOT be included here. + val androidxComposeVersion = libs.version("androidx-compose-bom-aligned") val cmpAlignedGroups = setOf( "androidx.compose.animation", "androidx.compose.foundation", @@ -51,7 +53,7 @@ internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { configurations.configureEach { resolutionStrategy.eachDependency { if (requested.group in cmpAlignedGroups) { - useVersion(cmpVersion) + useVersion(androidxComposeVersion) } else if (requested.group == "androidx.compose.material") { useVersion(materialVersion) } diff --git a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt index aa222b7c2..f930b9ba5 100644 --- a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt +++ b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt @@ -17,7 +17,7 @@ package org.meshtastic.core.barcode import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a524e77d..550d9dc20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,21 +33,22 @@ testRetry = "1.6.4" 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 +compose-multiplatform = "1.11.0-beta03" +compose-multiplatform-material3 = "1.11.0-alpha07" +# `androidx-compose-bom-aligned` tracks androidx.compose.{runtime,ui,foundation,animation} # 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-rc01" +# hasn't published yet (see PR #5180). Should track the AndroidX version that the +# current `compose-multiplatform` release maps to (see CMP release notes). +# AndroidCompose.kt's resolutionStrategy force-aligns these groups to *this* version +# at resolution time, so it is the source of truth for the Android target. +androidx-compose-bom-aligned = "1.11.0" # `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. androidx-compose-material = "1.7.8" -jetbrains-adaptive = "1.3.0-alpha06" +jetbrains-adaptive = "1.3.0-alpha07" # Google maps-compose = "8.3.0" From 2dcf01a02ba7bc328f05251d30cf3c4826976f26 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:09:31 -0500 Subject: [PATCH 58/65] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5231) --- .../composeResources/values-et/strings.xml | 38 +++++ .../composeResources/values-ru/strings.xml | 144 +++++++++++------- .../composeResources/values-uk/strings.xml | 143 +++++++++++++++++ .../android/ru-RU/changelogs/default.txt | 2 +- .../android/ru-RU/full_description.txt | 8 +- 5 files changed, 276 insertions(+), 59 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index ecd9a422d..49a58fb9e 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -114,6 +114,10 @@ Lühike ulatus - turbo Lühike ulatus - kiire Lühike ulatus - aeglane + Kerge - Kiire + Kerge - Aeglane + Kitsas - Kiire + Kitsas - Aeglane WiFi lubamine keelab rakenduses Bluetooth-ühenduse. Etherneti lubamine keelab sinihamba ühenduse rakendusega. TCP-sõlmede ühendused pole Apple'i seadmetes saadaval. Luba kohalikus võrgus pakettide edastamine UDP kaudu. @@ -176,6 +180,8 @@ Võrguseadmeid ei leitud USB seadmeid ei leitud USB + BLE + TCP USB Demo režiim Ühendatud raadioga, aga see on unerežiimis @@ -792,6 +798,13 @@ Hosti mõõdik Pax mõõdiku küsimine Metaandmed + Värskenda metaandmeid + Ühenda & ja halda + Kaugühenduse loomine… + Sessioon aktiivne + Taaskäivitus vajalik + Ühenda raadioga kaugsõlmede haldamiseks. + Sõlmega ei õnnest ühenduda – proovi uuesti või liigu lähemale. Proovi uuesti Toimingud Püsivara @@ -903,10 +916,24 @@ Püsivara versioon Hiljuti nähtud seadmed Avastatud seadmed + Võrguseadmete otsimine Otsin… + Otsi sinihamba seadmeid Otsin… Saadaval olevad sinhamba seadmed + Lisa sead käsitsi… + Ühtegi seadet ei leitud + Sinihamba-seadmeid ei tuvastatud + Veendu, et oled seadme levialas. + Võrgu seadmeid ei tuvastatud + Veendu, et oled seadmega samas võrgus. + USB seadmeid ei tuvastatud + Ühenda seade jadapordi või USB kaudu. Ühtegi seadet pole ühendatud + Loo ühendus lähedal avastatud sõlmedega. + Otsin võrgusõlmi + Lähedal asuvad sõlmed kuvatakse siin kohe, kui need avastatakse. + Ühenduse loomine Algusesse Teretulemast Igal pool ühenduses @@ -1226,7 +1253,18 @@ Sisestage või valige võrk WiFi edukalt seadistatud! WiFi sätete rakendamine ebaõnnestus + Seade ühendatud + Sinu mPWRD-OS seade on WiFi-võrguga ühendatud. + IP aadress + Seadme seadistamise lõpetamine + Logi sisse SSH kaudu, et muuta vaike kasutajanime ja -parooli. Kasutajatunnus + SSH käsk + ssh %1$s@%2$s + SSH käsk on saadaval peale IP aadressi määramist. + Ava SSH klient + Kui rakendus ei avane, kopeeri SSH käsk ja kleebi see oma SSH klienti. + IP aadress pole saadaval Valmis Meshtastic töölaud Näita Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index 91aadb6a1..cdd7489e6 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -45,7 +45,7 @@ Нераспознанный Ожидание подтверждения В очереди на отправку - Доставляется в сеть + Отправлено в сеть Неизвестно Маршрутизация по SF++ цепочке… Подтверждено в цепочке SF++ @@ -108,35 +108,39 @@ Большая дальность - Быстрый Большая дальность - Турбо Большая дальность - Умеренно - Большая дальность - Медленный - Средняя дальность - Быстрый - Средняя дальность - Медленный + Большая дальность - Медленно + Средняя дальность - Быстро + Средняя дальность - Медленно Малая дальность - Турбо - Малая дальность - Быстрый - Малая дальность - Медленный + Малая дальность - Быстро + Малая дальность - Медленно + Легкий - быстро + Легкий - медленно + Узкий - быстро + Узкий - медленно Включение WiFi отключит Bluetooth-подключение к приложению. Включение Ethernet отключит Bluetooth-соединение с приложением. TCP-соединения не доступны на устройствах Apple. Включить вещание пакетов через UDP в локальной сети. - Максимальный интервал, который может пройти без передачи позиций нод. + Максимальное время между передачами позиции узлом. Чем меньше расстояние, тем быстрее будет отправляться обновление позицию. - Минимальное изменение расстояния в метрах для рассылки смарт-позиции. - Как часто мы пытаемся получить местоположение GPS (<10sec держит GPS включенным). - Необязательные поля для включения при сборке сообщений о местоположении. Чем больше полей будет включено, тем больше будет сообщение, что приведет к увеличению времени трансляции и повышению риска потери пакетов. - Все устройства будут работать в режиме ожидания, насколько это возможно, в качестве трекера и датчика также будет использоваться радиоприемник lora. Не используйте эту настройку, если вы хотите использовать свое устройство с приложениями для телефона или же устройство без кнопки взаимодействий. - Сгенерировано из вашего приватного ключа и отправлено другим нодам в сети, чтобы они могли вычислить общий секретный ключ. + Минимальное расстояние для умной рассылки координаты. + Как часто запрашивать координаты GPS (<10 секунд держит GPS включенным постоянно). + Необязательные поля в сообщении о местоположении. Чем больше полей включено, тем больше сообщение, а значит больше время передачи и риск потери пакетов. + Все компоненты устройства будут засыпать, насколько возможно. Для ролей TRACKER или SENSOR также будет засыпать радио LoRa. Не включайте режим, если используете узел с телефоном или у узла нет кнопки для пробуждения. + Сгенерировано из вашего приватного ключа и отправлено другим узлам сети, чтобы они могли вычислить совместный секретный ключ. Используется для создания общего ключа с удаленным устройством. - Открытый ключ для отправки сообщения администратора на данную ноду - Устройство управляется администратором сетки, пользователь не может получить доступ к настройкам устройства. + Открытый ключ узла администратора, имеющего право на управление данным узлом отсылкой административных сообщений. + Устройство управляется сетевым администратором, пользователь не может изменять настройки устройства. Последовательная консоль через Stream API. - Выводите журнал отладки в режиме реального времени по последовательному каналу, просматривайте и экспортируйте журналы устройств с измененным местоположением по Bluetooth. + Вывод журнала отладки по последовательному каналу, просматривайте и выгружайте журналы устройства с измененным местоположением по Bluetooth. Пакет позиции - Период трансляции + Период рассылки Умная позиция Умный интервал Умное расстояние GPS устройства - Фиксированное положение + Фиксированнык координаты Высота Интервал опроса GPS Расширенное GPS устройство @@ -144,23 +148,23 @@ GPIO передачи GPS GPIO EN GPS GPIO - Debug - Кан + Отладка + Кнл Имя канала QR-код - Неизвестное имя пользователя + Неизвестный пользователь Отправить - Вы + В Разрешить аналитику и отчеты о сбоях. Принять Отмена Отмена Сохранить - URL нового канала получен + Получен URL нового канала Отчет - Доступ к местоположению выключен, невозможно посылать местоположение в сеть. + Доступ к местоположению выключен, невозможно отправлять координаты в сеть. Поделиться - Возникла новая нода - %1$s + Новый узел - %1$s Отключено Устройство спит IP-адрес: @@ -177,9 +181,10 @@ Устройства USB не найдены USB BLE + TCP USB Демо-режим - Подключен к радиостанции, но она спит + Подключен к узлу, но он спит Требуется обновление приложения Вам необходимо обновить данное приложение в магазине приложений (или с Github). Оно слишком старо для взаимодействия с прошивкой радиостанции. Пожалуйста, прочитайте нашу документацию по этой теме. Нет (выключить) @@ -191,9 +196,9 @@ Этот URL-адрес канала недействителен и не может быть использован Панель отладки Декодированная нагрузка: - Экспортировать логи - %1$d журналов экспортировано - Не удалось записать файл журнала: %1$s + Выгрузить логи + %1$d журналов выгружено + Не удалось записать журнал: %1$s %1$d час %1$d часа @@ -215,20 +220,20 @@ Добавить фильтр Фильтр включен Очистить все фильтры - Добавить пользовательский фильтр - Предустановленные фильтры + Добавить свой фильтр + Готовые фильтры Хранить журналы mesh-сети Выключить запись сетевых журналов на диск Очистить журнал Совпадение любой | Все Совпадение всех | Любой - Это удалит все пакеты журналов и записи базы данных с вашего устройства. Это — полный сброс, и он необратим. + Это удалит все журналы и базы данных с вашего устройства. Это — полный сброс, и он необратим. Очистить Поиск эмодзи... Больше реакций Канал %1$s: %2$s - Сообщение от %1$s: %2$s + От %1$s: %2$s Заголовок Предмет %1$d Футер @@ -313,7 +318,7 @@ Прямое сообщение Очистка списка нод сети Доставка подтверждена - Ваше устройство может отключиться и перезагрузиться во время применения настроек. + Ваше устройство может отключиться и перезагрузиться во время применении настроек. Ошибка Неизвестная ошибка Игнорировать @@ -424,16 +429,16 @@ Продолжительность: %1$s с Обратный маршрут:\n\n Маршрут к нам:\n\n - Хопов вперёд - Хопов обратно - Круговой маршрут + Передач к цели + Передач от цели + Полное время Без ответа - Загрузка 1м - Загрузка 5м - Загрузка 15м - Среднее значение нагрузки системы за 1 минуту - Среднее значение нагрузки системы за 5 минут - Среднее значение нагрузки системы за 15 минуту + Загрузка 1 мин + Загрузка 5 мин + Загрузка 15 мин + Средняя нагрузка системы за 1 минуту + Средняя нагрузка системы за 5 минут + Средняя нагрузка системы за 15 минуту Доступная оперативная память в байтах 24ч @@ -467,7 +472,7 @@ Вы уверены? документацию о ролях устройств и пост в блоге, а именно выбор правильной роли устройства.]]> Я знаю, что делаю. - У ноды %1$s низкий заряд (%2$d%) + У узла %1$s низкий заряд (%2$d%) Уведомление о низком уровне заряда Низкий заряд батареи: %1$s Уведомления о низком заряде батареи (избранные ноды) @@ -622,10 +627,10 @@ Неактивно Отключено Отключено — %1$s - Подключение... + Подключение… Подключено - Переподключение... - Переподключение (попытка %1$d) — %2$s + Восстановление связи… + Восстановление (попытка %1$d) — %2$s Проверить соединение Проверяем брокер… Доступно. Брокер принял учетные данные. @@ -801,6 +806,13 @@ Метрики хоста Метрика прохожих Метаданные + Обновить метаданные + Подключение и управление + Установка удалённой сессии… + Сессия активна + Требуется обновление + Подключитесь к узлу для управления удалёнными узлами(нодами). + Не удается подключиться к узлу— попробуйте еще раз или подойдите ближе. Повторить Действия Прошивка @@ -912,10 +924,24 @@ Версия прошивки Недавние сетевые устройства Найденные сетевые устройства + Сканировать сетевые устройства Поиск... + Сканировать Bluetooth-устройства Поиск... Доступные Bluetooth-устройства + Добавить устройство вручную… + Устройства не найдены + Bluetooth-устройства не обнаружены + Убедитесь, что вы находитесь в зоне действия устройства. + Сетевые устройства не обнаружены + Убедитесь, что вы подключены к той же сети, что и устройство. + USB-устройства не обнаружены + Подключите устройство через последовательный порт или USB. Нет подключенных устройств + Подключитесь к устройству, чтобы обнаружить ближайшие узлы. + Поиск узлоов + Ближайшие узлы будут появляться здесь по мере их обнаружения. + Настроить соединение Начать работу Добро пожаловать в Оставайтесь на связи везде @@ -1152,7 +1178,7 @@ Беспроводное управление настройками устройства и каналами. Выбор стиля карты Батарея: %1$d - Нод: %1$d онлайн / %2$d всего + Узлы: %1$d онлайн / %2$d всего Время работы: %1$s ChUtil: %1$s% | AirTX: %2$s% Traffic: TX %1$d / RX %2$d (D: %3$d) @@ -1198,7 +1224,7 @@ Штаб-квартира Снайпер Санитар - Наблюдатель + Вперёдсмотрящий Оператор радиотелефона Собака (К9) Управление движением @@ -1211,15 +1237,15 @@ Макс кол-во хопов для прямых сообщений Ограничение скорости Окно ограничения скорости (сек.) - Макс количество пакетов в окне + Макс пакетов в окне Отбрасывать неизвестные пакеты - Порог передачи неизвестного пакета - Телеметрия только для локальной сети (ретрансл.) - Только локальная позиция (ретрансл.) + Порог передач неизвестных пакетов + Телеметрия только локальной сети (ретрансл.) + Только локальные позиции (ретрансл.) Сохраняить хопы маршрутизатора Примечание Хранилище устройства и UI (только для чтения) - Тема: %1$s, язык: %2$s + Тема: %1$s, Язык: %2$s Доступные файлы (%1$d): - %1$s (%2$d байт) Файлы не отобразились. @@ -1232,7 +1258,7 @@ Найденное устройство Готов к сканированию Wi-Fi сетей. Поиск сетей - Поиск... + Поиск… Применение настроек Wi-Fi… Сети не найдены Не удалось подключиться: %1$s @@ -1243,8 +1269,18 @@ Введите или выберите сеть Wi-Fi успешно настроен! Не удалось применить настройку Wi-Fi + Устройство подключено + Ваше устройство mPWRD-OS подключилось к сети Wi-Fi. IP-адрес + Полная настройка устройства + Войдите через SSH, чтобы изменить имя пользователя и пароль по умолчанию. Имя пользователя + Команда SSH + ssh %1$s@%2$s + Команды SSH доступны после назначения IP-адреса. + Клиент OpenSSH + Если ни одно приложение не открывается, скопируйте команду SSH и вставьте её в ваш SSH-клиент. + IP недоступен Готово Meshtastic Desktop Показать Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index e92c80c8f..f5c47c234 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -18,6 +18,7 @@ Meshtastic + Meshtastic %1$s Фільтри очистити фільтр вузлів Фільтрувати за @@ -96,6 +97,7 @@ Перевернути екран по вертикалі. Одиниці, що показуються на екрані пристрою. Ручне керування виявленням OLED-екрану. + Перевизначити стандартну розмітку екрану. Виділяти текст заголовка на екрані жирним шрифтом. Вимагає наявність акселерометра на вашому пристрої. Регіон, де ви будете використовувати радіо. @@ -120,6 +122,7 @@ Увімкнення Ethernet вимкне Bluetooth-з'єднання до програми. З'єднання з вузлами через TCP недоступні на пристроях Apple. Увімкнути трансляцію пакетів через UDP через локальну мережу. Максимальний інтервал, після якого вузол обов'язково надсилає свої координати. + Найменший проміжок часу між розсилками координат при активному русі. Мінімальна дистанція в метрах, після подолання якої вузол оновить геопозицію. Як часто слід намагатися отримати позицію GPS (<10 сек тримає GPS постійно увімкненим). Додаткові поля для пакетів позиції. Чим більше полів вибрано, тим більшим буде розмір повідомлення, що збільшує час передачі в ефірі та ризик втрати пакетів. @@ -175,6 +178,7 @@ Не вибраний пристрій Невідомий пристрій Не знайдений мережевий пристрій + Не знайдений USB пристрій USB BLE TCP @@ -188,8 +192,10 @@ Подяки Бібліотеки з відкритим вихідним кодом Meshtastic побудований на бібліотеках з відкритим вихідним кодом. Натисніть на будь-яку бібліотеку, щоб побачити її ліцензію. + %1$d Бібліотеки URL-адреса цього каналу недійсна та не може бути використана Панель налагодження + Розшифровані дані: Експортувати журнали %1$d журналів експортовано Не вдалося записати файл журналу: %1$s @@ -217,14 +223,21 @@ Додати свій фільтр Готові фільтри Сховище для логування + Вимкнути запис логів мережі на диск Очистити журнал Відповідає будь-якому/всім Відповідає всім/будь-якому + Це видалить усі пакети логів та записи бази даних із вашого пристрою. Це повне скидання, воно є незворотним. Очистити Пошук смайлів... + Більше реакцій Канал + %1$s: %2$s + Повідомлення від %1$s: %2$s Заголовок + Елемент %1$d Підвал + Попередній перегляд Крапка Текст Шкала @@ -234,6 +247,8 @@ Статус доставки повідомлень Нові повідомлення нище Сповіщення особистих повідомлень + Сповіщення загального каналу + Сповіщення про точки маршруту Сповіщення про тривоги Потрібне оновлення прошивки. Прошивка радіо застаріла для зв’язку з цією програмою. Для отримання додаткової інформації дивіться наш посібник із встановлення мікропрограми. @@ -299,6 +314,7 @@ Скинути до заводських налаштувань Відкрити налаштування Версія прошивки: %1$s + Для пошуку та підключення до пристроїв через Bluetooth програмі Meshtastic потрібен дозвіл «Пристрої поблизу». Ви можете вимкнути його, коли не користуєтеся програмою. Пряме повідомлення Очищення бази вузлів Доставку підтверджено @@ -345,6 +361,10 @@ Наразі: Завжди в безшумному режимі Не в безшумному режимі + Режим без звуку %1$d днів, %2$s годин + Режим без звуку %1$s годин + Виключити сповіщення на '%1$s'? + Включити сповіщення на '%1$s'? Замінити Сканувати QR-код Wi-Fi Некоректний формат QR-коду з даними WiFi @@ -352,6 +372,11 @@ Батарея Завантаженість каналу Завантаженість ефіру + %1$s: %2$s%% + %1$s: %2$s Вольт + %1$s + %1$s: %2$s + Температура Вологість Температура ґрунту Вологість ґрунту @@ -388,6 +413,7 @@ Якість сигналу Маршрут Прямий + Стрибків вперед %1$d Стрибків назад %2$d Вихідний маршрут Відповідь Traceroute тим самим маршрутом Неможливо показати мапу трасування, оскільки початковий або кінцевий вузол не мають даних про позицію. @@ -401,8 +427,12 @@ Зворотні стрибки Тривалість кругового маршруту Немає відповіді + Навантаження 1 хвилина Навантаження 5 хвилин Навантаження 15 хвилин + Середнє навантаження системи за хвилину + Середнє навантаження системи за п'ять хвилин + Середнє навантаження системи за п'ятнадцять хвилин Доступно системної пам'яті в байтах 24Г @@ -436,10 +466,15 @@ Ви впевнені? ]]> Я знаю, що роблю. + Вузол %1$s має низький заряд батареї (%2$d%) Сповіщення про низький рівень заряду Низький заряд батареї: %1$s Сповіщення про низький рівень заряду акумулятора (улюблені вузли) + Бар Увімкнено + Останній виклик:%2$s
Остання позиція:%3$s
Батарея:%4$s]]>
+ Змінити мою позицію + Орієнтація північ Користувач Канали Пристрій @@ -456,42 +491,72 @@ Тест дальності Телеметрія + Шаблонне повідомлення Аудіо Віддалене обладнання Інформація про сусідів + Фонова підсвітка Датчик виявлення + Лічильник пристроїв Налаштування аудіо CODEC 2 увімкнено PTT контакт Частота дискретизації CODEC2 + Вибір слова I2S + Вибір входу I2S + Вибір виходу I2S + I2S тактування Налаштування Bluetooth Bluetooth увімкнено + Режим створення пари Фіксований PIN + Передача увімкнена + Прийом увімкнений За замовчуванням Місцезнаходження увімкнено + Точна позиція GPIO контакт Тип Приховати пароль Показати пароль Подробиці Середовище + Конфігурація фонової підсвітки Стан світлодіоду Червоний Зелений Синій + Конфігурація шаблонного повідомлення + Шаблонне повідомлення активоване Енкодер #1 активований + GPIO для енкодеру порт А + GPIO пін для енкодеру порт B + GPIO пін для енкодеру Кнопка + Генерувати подію при натисканні + Генерувати подію при повороті праворуч + Генерувати подію при повороті ліворуч + Увімкнути керування Вгору/Вниз/Вибір + Дозволити джерело введення Надіслати дзвіночок Повідомлення + Обмеження кешу списку вузлів Макс. кількість баз даних, що зберігаються на цьому телефоні + Термін зберігання логів мережі + Оберіть термін зберігання логів. Виберіть «Ніколи», щоб зберігати всі записи. Ніколи не видаляти журнали Налаштування датчика виявлення Датчик виявлення увімкнено + Мінімальний період розсилки (секунди) + Інтервал трансляції стану (секунди) + Надсилати дзвіночок з тривожним повідомленням Дружня назва GPIO контакт для моніторингу + Тип тригера виявлення Використовувати режим INPUT_PULLUP Роль пристрою GPIO кнопки GPIO гудка + Режим ретрансляції Інтервал розсилки даних про вузол Подвійний дотик як натискання кнопки Швидка перевірка зв'язку потрійним натисканням @@ -527,6 +592,10 @@ Тривалість виводу (мілісекунд) Інтервал нагадувань (секунди) Мелодія + Завантажена мелодія + Файл порожній + Помилка імпорту: %1$s + Відтворити Використовувати I2S як гудок LoRa Налаштування @@ -534,6 +603,7 @@ Використовувати пресет Пресети Ширина смуги пропускання + Показник розширення сигналу Швидкість кодування Регіон Кількість стрибків @@ -550,8 +620,21 @@ Налаштування MQTT Неактивний Відключено + Зв'язок розірвано - %1$s + З'єднання… Під’єднано + Роз'єднання… + Роз'єднання (спроба %1$d) - %2$s Перевірка зʼєднання + Перевірка брокеру… + Доступно. Брокер прийняв облікові дані. + Доступно (%1$s) + Брокер відхилений: %1$s + Хост не знайдено + Неможливо зв'язатися з брокером (TCP) + TLS рукопотискання невірне + Тайм-аут після %1$d мілісекунд + З'єднання неможливе MQTT увімкнений Адреса Ім'я користувача @@ -559,7 +642,10 @@ Шифрування увімкнено Вивід JSON увімкнено TLS увімкнений + Кореневий чат Проксі для клієнта увімкнуто + Відображення на мапі + Інтервал звітування на мапі (секунди) Налаштування інформації про сусідів Інформацію про сусідів увімкнено Інтервал оновлення (секунд) @@ -576,31 +662,60 @@ Режим IPv4 IP-адреса Шлюз + Підрегіон DNS + Конфігурація лічильника пристроїв + Лічильник пристроїв активований Статус повідомлення + Конфігурація статусу повідомлення + Рядок актуального статусу RSSI поріг WiFi (за замовчуванням -80) RSSI поріг BLE (за замовчуванням -80) Широта Довгота + Встановити з поточного місцеположення телефону + Режим GPS (Фізичний пристрій) + Прапорці місцеположення Налаштування живлення Увімкнути енергоощадний режим Вимкнути при втраті живлення + Корекція множника напруги + Множник корекції напруги + Тривалість очікування Bluetooth + Тривалість глибокого сну + Мінімальний час в робочому режимі + I2C адреса INA_2XX батареї Налаштування тесту дальності Тест на відстань увімкнений + Інтервал надсилання повідомлень (секунди) Зберегти .CSV у сховищі (лише ESP32) + Конфігурація віддаленого пристрою + Конфігурація віддаленого пристрою активована + Дозволити доступ до невизначених пінів Доступні піни + Ключ для прямого повідомлення Ключ адміністратора Відкритий ключ Приватний ключ Ключ адміністратора + Керований режим Серійна консоль + API журналу відладки увімкнено + Застарілий адмін канал Налаштування послідовного порту Послідовний порт увімкнено + Відлуння активоване Швидкість послідовного порту + RX пін + TX пін Таймаут + Послідовний режим Перевизначити послідовний порт + Пульсація Кількість записів + Максимальний обсяг історії + Максимальне вікно історії Сервер Налаштування телеметрії Інтервал оновлення показників пристрою @@ -619,12 +734,23 @@ Довга назва Коротка назва Модель обладнання + Ліцензований радіоаматор (Ham) Включення цієї опції вимикає шифрування і не сумісне зі стандартною мережею Meshtastic. + Точка роси Атмосферний тиск + Опір газового сенсора Відстань + Люкс Вітер + Швидкість вітру + Порив вітру + Затишшя вітру + Напрямок вітру + Дощ (1 год) + Дощ (24 год) Вага Радіація + Датчик температури з 1 проводом Якість повітря в приміщенні (IAQ) URL @@ -639,17 +765,26 @@ Завантаження %1$d Вільне місце %1$d Мітка часу + Курс/Напрямок Швидкість + %1$d км/год + Супутники + Висота над рівнем моря Част Слот Основний + Періодична трансляція координат та телеметрії Вторинний + Немає періодичної трансляції телеметрії + Місцеположення лише за запитом Натисніть і перетягніть, щоб змінити порядок + Увімкнути звук Динамічна Поділитися контактом Нотатки Додати приватну нотатку… Імпортувати спільний контакт? + Недоступний для повідомлень Відкритий ключ змінено Імпортувати Запросити @@ -719,6 +854,12 @@ Введіть повідомлення Показники PAX PAX + PAX:%1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s Немає доступних показників PAX. Bluetooth пристрої Під'єднаний пристрій @@ -730,6 +871,8 @@ Підтримується спільнотою Meshtastic Тип прошивки Виявлені мережеві пристрої + Сканування… + Пошук пристроїв Bluetooth Сканування… Доступні Bluetooth пристрої Додати пристрій вручну… diff --git a/fastlane/metadata/android/ru-RU/changelogs/default.txt b/fastlane/metadata/android/ru-RU/changelogs/default.txt index 7068f45a3..3907315b8 100644 --- a/fastlane/metadata/android/ru-RU/changelogs/default.txt +++ b/fastlane/metadata/android/ru-RU/changelogs/default.txt @@ -1 +1 @@ -Для подробных заметок о выпуске, пожалуйста, посетите: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +Подробные заметки о выпуске, пожалуйста, смотрите здесь: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt index 4dcfa6e77..e4c9eed21 100644 --- a/fastlane/metadata/android/ru-RU/full_description.txt +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -1,10 +1,10 @@ Meshtastic является инструментом для использования Android-устройств с Mesh сетью с открытым исходным кодом. Это приложение является основным клиентом Meshtastic проекта, которое позволяет вам управлять вашими mesh устройствами и общаться с другими пользователями. -Для получения дополнительной информации о проекте Meshtastic, пожалуйста, посетите наш веб-сайт: meshtastic.org. Прошивка, которая работает на радиоустройствах, является отдельным проектом с открытым исходным кодом, который вы можете найти здесь: https://github.com/meshtastic/Meshtastic-device. +Для получения дополнительной информации о проекте Meshtastic, пожалуйста, посетите наш веб-сайт: meshtastic.org. Прошивка узла радиосети, является отдельным проектом с открытым исходным кодом, который находится здесь: https://github.com/meshtastic/Meshtastic-device. Сообщество и поддержка -Этот проект находится в бета-версии. Нам важно ваше мнение! Если у вас есть вопросы, обратная связь или проблемы, пожалуйста, присоединяйтесь к нашему дружному и активному сообществу: +Этот проект находится в бета-версии. Нам интересно ваше мнение! Если у вас есть вопросы, отклики или вы заметили проблему, пожалуйста, присоединяйтесь к нашему дружному и активному сообществу: • Форум для обсуждений: https://github.com/orgs/meshtastic/discussionsDiscord: https://discord.gg/meshtastic @@ -12,8 +12,8 @@ Meshtastic является инструментом для использова Документация -Чтобы узнать больше о функциях и возможностях этого приложения и Meshtastic, пожалуйста, ознакомьтесь с нашей официальной документацией: -Просмотреть документацию +Чтобы узнать больше о функциях и возможностях приложения и сети Meshtastic, используйте нашу официальную документацию: +Читать документацию Переводы From cbf7d263c4e9b71cc95802c62a1f9a1f1a2b5fd7 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:10:36 -0500 Subject: [PATCH 59/65] fix(desktop): suppress Vico ColorScale ProGuard warnings (#5232) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- desktop/proguard-rules.pro | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index 280214b2e..77ea4d6f2 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -69,3 +69,15 @@ -dontwarn kotlin.concurrent.atomics.** -dontwarn kotlin.uuid.UuidV7Generator + +# ---- Vico 3.2.0-next.1 ColorScale (CMP API drift) --------------------------- +# Vico's new ColorScale* classes (ColorScaleShader, ColorScaleAreaFill, +# ColorScaleLineFill) reference CMP UI graphics members that don't exist in +# compose-multiplatform 1.11.0-beta03 (LinearGradientShader-VjE6UOU$default +# on ShaderKt and Paint.setShader(org.jetbrains.skia.Shader)). We don't use +# the ColorScale APIs in this app, so suppress these warnings to let ProGuard +# proceed; otherwise it aborts with "unresolved program class members". +# Remove once Vico ships a release built against CMP 1.11 stable. +-dontwarn com.patrykandpatrick.vico.compose.cartesian.ColorScaleShader +-dontwarn com.patrykandpatrick.vico.compose.cartesian.layer.ColorScaleAreaFill +-dontwarn com.patrykandpatrick.vico.compose.cartesian.layer.ColorScaleLineFill From 37ac422331c1aaeece54e79421c05d8572a65652 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:13:27 -0500 Subject: [PATCH 60/65] fix(desktop): unbreak Windows launch + Pi-installable arm64 .deb (#5233) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- .../meshtastic/buildlogic/KotlinAndroid.kt | 22 +++------- desktop/proguard-rules.pro | 44 ++++++++++++++++--- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d7f8e012..df78d3306 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -258,7 +258,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm] + os: [macos-latest, windows-latest, ubuntu-22.04, ubuntu-22.04-arm] env: GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index 088ca0d25..e3dd8db15 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -72,22 +72,12 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) { /** Configure Kotlin Multiplatform options */ internal fun Project.configureKotlinMultiplatform() { - // Skiko is an internal CMP implementation detail; third-party KMP libraries - // (e.g. coil3) can carry an older skiko transitive requirement that Gradle - // upgrades to the CMP-bundled version, triggering a "Skiko dependencies' - // versions are incompatible" warning from CMP's compatibility checker. - // Force the version to match CMP so the checker sees a consistent graph. - // Pinned here rather than in the version catalog because this plugin is the - // only consumer — bump together with the compose-multiplatform version. - val skikoVersion = "0.144.5" - configurations.configureEach { - resolutionStrategy.eachDependency { - if (requested.group == "org.jetbrains.skiko") { - useVersion(skikoVersion) - because("Align Skiko with the version bundled by Compose Multiplatform") - } - } - } + // Note: we used to force `org.jetbrains.skiko` to a hard-coded version here to + // align coil3's older skiko requirement with CMP's. As of CMP 1.11.x the + // compose-desktop module publishes `{strictly }` constraints on + // skiko, so Gradle resolves the conflict naturally. A hard-coded force would + // silently downgrade skiko on the next CMP bump and break the renderer — + // so we let CMP own the version. extensions.configure { // Standard KMP targets for Meshtastic diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index 77ea4d6f2..9a9653e87 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -42,13 +42,7 @@ -dontprocesskotlinmetadata # ---- Entry point ------------------------------------------------------------ - --keep class org.meshtastic.desktop.MainKt { *; } - -# ---- Ktor Java engine (desktop-only; Android uses OkHttp) ------------------- -# io.ktor.client.engine.java ships consumer rules; the shared -# HttpClientEngineFactory ServiceLoader keep in shared-rules.pro covers the -# reflective discovery path. +# (org.meshtastic.desktop.MainKt is covered by the package-wide keep below.) # ---- Meshtastic desktop host shell ------------------------------------------ @@ -70,6 +64,42 @@ -dontwarn kotlin.concurrent.atomics.** -dontwarn kotlin.uuid.UuidV7Generator +# ---- Library consumer rules ------------------------------------------------ +# The compose-jb gradle plugin auto-injects `default-compose-desktop-rules.pro` +# (bundled inside org.jetbrains.compose:compose-gradle-plugin) into every +# desktop ProGuard run. That file already covers: +# - kotlin.**, kotlinx.coroutines.** (incl. SwingDispatcherFactory ServiceLoader) +# - org.jetbrains.skiko.**, org.jetbrains.skia.** +# - kotlinx.serialization.** (incl. @Serializable companion keeps) +# - kotlinx.datetime.** +# - androidx.compose.runtime SnapshotStateKt + Material3 SliderDefaults +# So we DO NOT re-declare those here. Source of truth: +# https://github.com/JetBrains/compose-multiplatform/blob/master/gradle-plugins/compose/src/main/resources/default-compose-desktop-rules.pro +# +# However, the standalone ProGuard 7.7.0 that compose-jb invokes does NOT +# auto-import library `META-INF/proguard/*.pro` consumer rules from arbitrary +# jars (only R8/Android does). So any consumer-rule pattern outside the bundled +# defaults above must be copied here manually (see Ktor SL block below). + +# ---- androidx.sqlite bundled driver (JNI native bridge) --------------------- +# BundledSQLiteDriver loads `libsqliteJni` and the native code calls back into +# JVM-land via methods on `BundledSQLiteDriverKt` (e.g. `nativeThreadSafeMode`) +# and member methods on `BundledSQLiteDriver` itself. Because those JVM symbols +# are referenced only from native code, ProGuard removes them as unused; the +# native loader then crashes with `NoSuchMethodError: ... name or signature does +# not match`. Keep the whole driver package — it's small and entirely needed at +# runtime once the bundled SQLite driver is selected. +-keep class androidx.sqlite.driver.bundled.** { *; } +-keepclassmembers class androidx.sqlite.driver.bundled.** { native ; *; } + +# ---- Ktor serialization extension providers (ServiceLoader) ----------------- +# io.ktor.serialization.kotlinx-json discovers KotlinxSerializationJsonExtensionProvider +# via META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider. +# Without this keep the desktop HttpClient init throws ServiceConfigurationError +# at first request; on Windows jpackage's launcher swallows the trace and +# surfaces it as "Failed to launch JVM". +-keep class * implements io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider { *; } + # ---- Vico 3.2.0-next.1 ColorScale (CMP API drift) --------------------------- # Vico's new ColorScale* classes (ColorScaleShader, ColorScaleAreaFill, # ColorScaleLineFill) reference CMP UI graphics members that don't exist in From dea9d86c523996575402d4b72703e83ea8dab1d5 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:41:17 -0500 Subject: [PATCH 61/65] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5235) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-uk/strings.xml | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index f5c47c234..e64a0ce85 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -413,6 +413,12 @@ Якість сигналу Маршрут Прямий + + 1 стрибок + %1$d cтрибків + %1$d стрибків + %1$d стрибків + Стрибків вперед %1$d Стрибків назад %2$d Вихідний маршрут Відповідь Traceroute тим самим маршрутом @@ -798,6 +804,8 @@ Показники хоста Показники Pax Метадані + Оновити метаданні + Сессія активна Повторити Дії Прошивка @@ -807,13 +815,18 @@ Хост Вільна пам'ять Завантажити + Користувацький рядок + Навігаційна інформація Підключення Мапа мережі Бесіди Вузли Налаштування + Вибраний Встановіть ваш регіон Відповісти + Ваш вузол періодично надсилатиме незашифрований пакет звіту на налаштований MQTT-сервер. Він містить: ID, повні та короткі назви, приблизне місцезнаходження, модель пристрою, роль, версію прошивки, регіон LoRa, пресет модему та назву основного каналу. + Згода на поширення незашифрованих даних вузла через MQTT Увімкнувши цю функцію, ви визнаєте і прямо погоджуєтесь з передачею географічного розташування вашого пристрою в режимі реального часу через протокол MQTT без шифрування. Ці дані можуть використовуватися для таких цілей, як відображення розташування на мапах, відстеження пристроїв і пов'язаних з цим функцій телеметрії. Я прочитав і розумію, текст вище. Я добровільно даю згоду на незашифровану передачу даних мого вузла через MQTT Погоджуюся. @@ -822,16 +835,36 @@ Діє до Час Дата + Фільтр мапи\n Лише обрані + Показати точки маршруту + Показати точне коло + Клієнтські повідомлення + Ключ верифікації + Запит ключа верифікації + Перевірку ключа завершено + Виявлено дублікат публічного ключа + Виявлено слабий ключ шифрування + Виявлено компрометацію ключів. Натисніть «ОК», щоб створити нові. Згенерувати закритий ключ Ви впевнені, що хочете новий закритий ключ?\n\nВузли, які, можливо, раніше обмінялись ключами з цим вузлом, повинні будуть видалити даний вузол і знову обмінятись ключами щоб відновити безпечний зв'язок. Експортувати ключі + Експортує відкриті та закриті ключі у файл. Будь ласка, збережіть їх в надійному місці. + Модулі розблоковано + Модулі вже розблоковано + Віддалений (%1$d онлайн / %2$d показані / %3$d загалом) + Реакція Від'єднатись Прокрутити донизу Meshtastic + Статус безпеки + Безпека + Значок попередження Невідомий канал Попередження + Додаткове меню + УФ Люкс Невідомий Цей пристрій керований та може бути змінений тільки віддаленим адміністратором. Розширені @@ -840,16 +873,26 @@ Очистити лише невідомі вузли Очистити зараз Це призведе до вилучення %1$d вузлів з вашої бази даних. Цю дію не можна скасувати. + Зелений замочок означає, що канал надійно зашифрований 128-бітним або 256-бітним ключем AES. + Незахищений канал, низька точність + Жовтий відкритий замочок означає, що канал не має надійного шифрування, не використовується для передачі точних координат і не має ключа або використовує загальновідомий 1-байтний ключ. + Незахищений канал, висока точність + Червоний відкритий замочок означає, що канал не має надійного шифрування, передає точні координати та не має ключа або використовує загальновідомий 1-байтний ключ. + Увага: Канал незахищений, точна локація& та передача на MQTT + Червоний відкритий замочок із попередженням означає, що канал не має надійного шифрування, передає точні координати в інтернет через MQTT і не має ключа або використовує загальновідомий 1-байтний ключ. Безпека каналу + Пояснення рівнів безпеки Показати всі значення Показати поточний статус Відхилити + Переслано до %1$s Скасувати відповідь Видалити повідомлення? + Очистити вибір Повідомлення Введіть повідомлення Показники PAX @@ -861,8 +904,10 @@ BLE: %1$s WiFi: %1$s Немає доступних показників PAX. + Налаштування Wi-Fi для mPWRD-OS Bluetooth пристрої Під'єднаний пристрій + Перевищено ліміт запитів. Будь ласка, спробуйте пізніше. Переглянути реліз Завантажити Наразі встановлено @@ -870,13 +915,16 @@ Остання альфа Підтримується спільнотою Meshtastic Тип прошивки + Нещодавні пристрої в мережі Виявлені мережеві пристрої + Пошук мережевих пристроїв Сканування… Пошук пристроїв Bluetooth Сканування… Доступні Bluetooth пристрої Додати пристрій вручну… Пристрої не знайдено + Пристроїв Bluetooth не виявлено Немає під'єднаних пристроїв З чого почати Ласкаво просимо до @@ -918,16 +966,19 @@ Значення іконок Налаштування пристрою Надсилати телеметрію пристрою + Будь-який 1 година 8 Годин 24 Годин 48 Годин + Фільтрувати за часом останньої активності:%1$s %1$d дБм Системі налаштування Статистика відсутня Аналітика збирається для того, щоб допомогти нам покращити додаток для Android (дякуємо), ми будемо отримувати анонімну інформацію про поведінку користувачів. Це включає звіти про збої, екрани, що використовуються в програмі й тому подібне. Аналітичні платформи: Для додаткової інформації, перегляньте нашу політику конфіденційності. + Невстановлене - 0 %1$s зазвичай постачається із завантажувачем, який не підтримує оновлення OTA. Вам може знадобитися завантажувач з можливістю оновлень OTA через USB перед прошиванням OTA. Докладніше Не показувати знову для цього пристрою @@ -1019,14 +1070,41 @@ Усі Bluetooth Налаштування + Батарея: %1$d % + %1$d / %2$d + %1$s + Працює + Оновити + Оновлено + Білий + Жовтий + Помаранчевий + Фіолетовий Червоний + Бордовий + Пурпурний + Темно синій Синій + Ціановий + Бірюзовий Зелений + Темно зелений + Коричневий + Невизначений + Член команди + Лідер команди + Штаб + Снайпер + Санітар + Спостерігач + Телефоніст + Собака (K9) Під’єднатися Готово Адреса IP Ім'я користувача + ssh %1$s@%2$s Готово Meshtastic Фільтри From 2e6730d1e3262022818edfbc23330fbc2d607adf Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:33:16 -0500 Subject: [PATCH 62/65] fix(desktop): unbreak release crash via correct ProGuard rules (#5236) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/buildlogic/AndroidCompose.kt | 11 +- config/proguard/shared-rules.pro | 229 ++++++++---------- desktop/build.gradle.kts | 6 + desktop/proguard-rules.pro | 133 ++++------ gradle/libs.versions.toml | 6 +- 5 files changed, 155 insertions(+), 230 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt index d3cfceb2a..4169f8841 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt @@ -44,17 +44,16 @@ internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { "androidx.compose.runtime", "androidx.compose.ui", ) - - // The BOM exclusion above strips versions from transitive material deps - // (e.g. maps-compose-widgets, datadog). Pin the material group to the - // AndroidX version that matches this CMP release. + // The BOM exclusion above strips the version from `androidx.compose.material:material` + // requested by maps-compose-widgets (google flavor). Pin only that artifact — the + // group also contains `material-ripple`, which CMP publishes at the bom-aligned + // version and must not be force-downgraded. val materialVersion = libs.version("androidx-compose-material") - configurations.configureEach { resolutionStrategy.eachDependency { if (requested.group in cmpAlignedGroups) { useVersion(androidxComposeVersion) - } else if (requested.group == "androidx.compose.material") { + } else if (requested.group == "androidx.compose.material" && requested.name == "material") { useVersion(materialVersion) } } diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro index 8d0d8efde..eb4ebd7fd 100644 --- a/config/proguard/shared-rules.pro +++ b/config/proguard/shared-rules.pro @@ -1,166 +1,135 @@ # ============================================================================ # Meshtastic — Shared ProGuard / R8 rules # ============================================================================ -# Cross-platform keep and dontwarn rules applied to BOTH the Android app -# release build (R8) and the Desktop distribution (ProGuard). Host-specific -# rules live in the per-module proguard-rules.pro file. +# Cross-platform keep rules applied to BOTH the Android app release (R8) and +# the Desktop distribution (ProGuard 7.7 invoked by compose-jb). # -# Rule of thumb: anything describing a library shared between Android and -# Desktop (Koin, kotlinx-serialization, Wire, Room KMP, Ktor, Coil 3, Kable, -# Kermit, Okio, DataStore, Paging, Lifecycle / Navigation 3, AboutLibraries, -# Markdown renderer, QRCode, Compose Multiplatform resources, core modules) -# belongs here. Anything platform-specific (AWT/Skiko/JNA, AIDL, Android -# framework, JDK-version quirks, flavor specifics) stays in the host file. +# IMPORTANT: compose-jb's standalone ProGuard task does NOT auto-include +# `META-INF/proguard/*.pro` consumer rules from dependency jars (only R8 on +# Android does — https://github.com/Guardsquare/proguard/issues/423). +# So this file inlines all the consumer rules we depend on for desktop. On +# Android these are duplicates of what R8 already auto-discovers, which is +# harmless. Per JetBrains compose-multiplatform docs: keep it as a single +# static .pro file and add rules as shrinking surfaces problems. # ============================================================================ # ---- Attributes ------------------------------------------------------------- +-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations,AnnotationDefault -# Preserve line numbers for meaningful stack traces, plus metadata needed for -# reflective serializer/DI/Room lookups. --keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations +# ---- Compose Multiplatform 1.11 optimizer defense (#5146) ------------------- +# CMP 1.11 ships consumer rules with `-assumenosideeffects` on +# Composer.() / ComposerImpl.() and `-assumevalues` on +# ComposeRuntimeFlags / ComposeStackTraceMode. The primary defence is +# `-dontoptimize` (set per-host in app/desktop proguard-rules.pro), which +# disables rewriting of these directives. Broad package-wide keeps below have +# been removed per R8_Configuration_Analysis.md as they are redundant — rely +# instead on CMP's own consumer rules + @DoNotInline annotations. If animations +# freeze in a future CMP/KGP release, replace with class-level keeps on the +# specific failure points (Composer, ComposerImpl, ComposeRuntimeFlags, +# ComposeStackTraceMode) rather than package-wide wildcards. -# ---- Kotlin / 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. +# ---- Compose Multiplatform resources ---------------------------------------- +-keep class org.meshtastic.core.resources.Res { *; } +-keepclassmembers class org.meshtastic.core.resources.Res$* { *; } -# ---- Koin DI (reflection-based injection) ----------------------------------- - -# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException -# replacing Koin's InstanceCreationException in stack traces, making crashes -# undiagnosable). Broadened to all of koin core to cover the KSP-generated graph. --keep class org.koin.** { *; } --dontwarn org.koin.** - -# Keep Koin-annotated modules/components so Koin Annotations (KSP) output -# survives tree-shaking. +# ---- Koin Annotations (KSP-generated DI graph) ------------------------------ -keep @org.koin.core.annotation.Module class * { *; } -keep @org.koin.core.annotation.ComponentScan class * { *; } -keep @org.koin.core.annotation.Single class * { *; } -keep @org.koin.core.annotation.Factory class * { *; } -keep @org.koin.core.annotation.KoinViewModel class * { *; } -# ---- kotlinx-serialization -------------------------------------------------- +# ---- kotlinx.coroutines (inlined from coroutines.pro consumer rules) -------- +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} +-keepclassmembers class kotlin.coroutines.SafeContinuation { + volatile ; +} +-dontwarn java.lang.instrument.ClassFileTransformer +-dontwarn sun.misc.SignalHandler +-dontwarn java.lang.instrument.Instrumentation +-dontwarn sun.misc.Signal +-dontwarn java.lang.ClassValue +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement --keep class kotlinx.serialization.** { *; } --dontwarn kotlinx.serialization.** - -# Keep @Serializable classes and their generated $serializer companions +# ---- kotlinx.serialization (inlined from kotlinx-serialization-common.pro) -- -keepclassmembers @kotlinx.serialization.Serializable class ** { static ** Companion; +} +-if @kotlinx.serialization.internal.NamedCompanion class * +-keepclassmembers class * { + static <1> *; +} +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { kotlinx.serialization.KSerializer serializer(...); } --keep class **.$serializer { *; } --keepclassmembers class **.$serializer { *; } --keepclasseswithmembers class ** { +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; kotlinx.serialization.KSerializer serializer(...); } +-dontnote kotlinx.serialization.** +-dontwarn kotlinx.serialization.internal.ClassValueReferences +-keepclassmembers public class **$$serializer { + private ** descriptor; +} -# ---- Wire Protobuf ---------------------------------------------------------- +# ---- kotlinx.datetime (inlined from datetime.pro consumer rules) ------------ +-dontwarn kotlinx.serialization.KSerializer +-dontwarn kotlinx.serialization.Serializable -# 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. +# ---- Ktor (inlined from ktor.pro + ServiceLoader gap) ----------------------- +-keepclassmembers class io.ktor.** { + volatile ; +} +-keepclassmembernames class io.ktor.** { + volatile ; +} +-keep class io.ktor.client.engine.** implements io.ktor.client.HttpClientEngineContainer +# ktor consumer rules preserve the ServiceLoader META-INF/services file but not +# the impl classes; ContentNegotiation discovers KotlinxSerializationJsonExtensionProvider +# reflectively and crashes with ServiceConfigurationError without these. +-keep class * implements io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider { *; } +-keep class io.ktor.serialization.kotlinx.json.** { *; } + +# ---- androidx.annotation.Keep (inlined from androidx-annotations.pro) ------- +-keep,allowobfuscation @interface androidx.annotation.Keep +-keep @androidx.annotation.Keep class * {*;} +-keepclasseswithmembers class * { @androidx.annotation.Keep ; } +-keepclasseswithmembers class * { @androidx.annotation.Keep ; } +-keepclasseswithmembers class * { @androidx.annotation.Keep (...); } +-keepclassmembers,allowobfuscation class * { + @androidx.annotation.DoNotInline ; +} + +# ---- androidx.datastore (inlined from datastore-preferences-core.pro) ------- +-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite { + ; +} + +# ---- Wire Protobuf (no consumer rules shipped) ------------------------------ -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). --dontwarn android.os.Parcel** --dontwarn android.os.Parcelable** +# ---- androidx.sqlite bundled driver (JNI native bridge) --------------------- +# androidx.sqlite-bundled's consumer rule keeps native methods only — but the +# bundled JNI library calls back into JVM methods on the driver class +# (e.g. `nativeThreadSafeMode`). Keep the whole driver package. +-keep class androidx.sqlite.driver.bundled.** { *; } +-keepclassmembers class androidx.sqlite.driver.bundled.** { native ; *; } # ---- Room KMP (room3) ------------------------------------------------------- - -# Preserve generated database constructors (Room uses reflection to instantiate) -keep class * extends androidx.room3.RoomDatabase { (); } -keep class * implements androidx.room3.RoomDatabaseConstructor { *; } - -# Keep the expect/actual MeshtasticDatabaseConstructor + database surface -keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } -keep class org.meshtastic.core.database.MeshtasticDatabase { *; } - -# Room's own consumer rules (from androidx.room3) keep DAOs, entities, -# generated _Impl classes, and TypeConverters referenced from the database. - -# ---- SQLite bundled -------------------------------------------------------- -# androidx.sqlite ships consumer rules. - -# ---- Ktor (ServiceLoader + plugin discovery) -------------------------------- - -# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory -# implementations reflectively via ServiceLoader). --keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } - -# ---- Coil 3 (image loading) ------------------------------------------------- -# coil3 ships consumer rules. - -# ---- Kable BLE -------------------------------------------------------------- -# 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 ---------------------------------------- - -# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.). -# 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.Res { *; } --keepclassmembers class org.meshtastic.core.resources.Res$* { *; } - -# ---- AboutLibraries --------------------------------------------------------- -# com.mikepenz.aboutlibraries ships consumer rules. - -# ---- Multiplatform Markdown Renderer ---------------------------------------- -# com.mikepenz.markdown ships consumer rules. - -# ---- QR Code Kotlin --------------------------------------------------------- - --keep class io.github.g0dkar.qrcode.** { *; } --dontwarn io.github.g0dkar.qrcode.** --keep class qrcode.** { *; } --dontwarn qrcode.** - -# ---- Kermit logging --------------------------------------------------------- -# co.touchlab.kermit ships consumer rules. - -# ---- Okio ------------------------------------------------------------------- -# okio ships consumer rules. - -# ---- DataStore -------------------------------------------------------------- -# androidx.datastore ships consumer rules. - -# ---- Paging ----------------------------------------------------------------- -# androidx.paging ships consumer rules. - -# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) ----------------- -# androidx.lifecycle and androidx.navigation3 ship consumer rules. - -# ---- Meshtastic shared 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 -------------------------------------------- - -# 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/desktop/build.gradle.kts b/desktop/build.gradle.kts index 58caf800b..2e66d6012 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -99,6 +99,12 @@ val generateBuildConfig = sourceSets.main { kotlin.srcDir(generateBuildConfig.map { buildConfigOutputDir }) } +// ── ProGuard configuration ─────────────────────────────────────────────────── +// compose-jb's standalone ProGuard 7.7 task does NOT auto-include +// `META-INF/proguard/*.pro` consumer rules from dependency jars (only R8 on +// Android does). We therefore inline every keep rule we need into the two +// static .pro files referenced below. + kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index 9a9653e87..f02c2c6cd 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -1,113 +1,64 @@ # ============================================================================ # Meshtastic Desktop — ProGuard rules for release minification # ============================================================================ -# Open-source project: we rely on tree-shaking (unused code removal) for size -# reduction. Obfuscation is disabled in build.gradle.kts (obfuscate.set(false)). +# Open-source: obfuscation is OFF (build.gradle.kts: obfuscate.set(false)). +# Tree-shaking still runs. # -# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, -# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, -# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in -# config/proguard/shared-rules.pro and are wired in by this module's -# build.gradle.kts. This file holds only desktop/JVM-specific rules. +# Two rule sources are merged into the ProGuard run: +# 1. JetBrains' bundled `default-compose-desktop-rules.pro` (auto-injected +# by the compose.desktop Gradle plugin). +# 2. Cross-platform project keeps in config/proguard/shared-rules.pro, +# which inlines every dependency consumer rule we need on desktop — +# compose-jb's standalone ProGuard task does NOT auto-discover +# `META-INF/proguard/*.pro` consumer rules from dependency jars (only +# R8 on Android does — https://github.com/Guardsquare/proguard/issues/423). +# +# This file only holds desktop/JVM-specific rules that aren't covered above. # ============================================================================ -# ---- General ---------------------------------------------------------------- - -# Suppress notes about duplicate resource files (common in fat JARs) --dontnote ** - -# Disable ProGuard optimization passes. Tree-shaking (unused code removal) still -# runs — only method-body rewrites and call-site transformations are suppressed. -# -# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on -# Composer.() and ComposerImpl.(), plus -assumevalues on -# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives -# let the optimizer rewrite *call sites* (class-init triggers, flag reads) even -# when the target classes are preserved by -keep rules. The result is that the -# Compose recomposer/frame-clock/animation state machines silently freeze on -# their first frame in release builds. -dontoptimize is the only directive that -# disables processing of -assumenosideeffects/-assumevalues. The desktop compose -# build sets optimize.set(true), so this applies here as well as to R8. See #5146. --dontoptimize - -# Do not parse/rewrite Kotlin metadata during shrinking/optimization. -# ProGuard's KotlinShrinker cannot handle the metadata produced by Compose -# Multiplatform 1.11.x + Kotlin 2.3.x, causing a NullPointerException. -# Since we disable obfuscation (class names remain stable), metadata references -# stay valid and do not need rewriting. The annotations themselves are preserved -# by -keepattributes *Annotation*. -# -# NOTE: -dontprocesskotlinmetadata is a ProGuard-only directive; R8 does not -# recognize it, which is why it lives in the desktop-only file. +# ---- ProGuard 7.7 + Kotlin 2.3 metadata workaround -------------------------- +# ProGuard 7.7's KotlinShrinker NPEs on metadata produced by CMP 1.11 + +# Kotlin 2.3.x. Because we don't obfuscate, class names stay stable and +# metadata references remain valid without rewriting. Annotations themselves +# are preserved by `-keepattributes *Annotation*` in shared-rules.pro. +# (R8-only directive equivalent does not exist; this is ProGuard-only.) -dontprocesskotlinmetadata +# ---- Disable optimizer (CMP 1.11 -assumenosideeffects defense) -------------- +# See shared-rules.pro for full rationale. Even though build.gradle.kts sets +# `optimize.set(true)` so compose-jb wires the optimization step, this rule +# turns it into a no-op — keeping CMP's `-assumenosideeffects` directives from +# rewriting Composer call sites and freezing the runtime. See #5146. +-dontoptimize + # ---- Entry point ------------------------------------------------------------ -# (org.meshtastic.desktop.MainKt is covered by the package-wide keep below.) - -# ---- Meshtastic desktop host shell ------------------------------------------ - -# Keep all desktop module classes (thin host shell — not worth tree-shaking) +# Keep the desktop host shell (thin module — not worth tree-shaking). -keep class org.meshtastic.desktop.** { *; } # ---- JVM runtime suppression ------------------------------------------------ - -dontwarn java.lang.reflect.** -dontwarn sun.misc.Unsafe -dontwarn java.lang.invoke.** -# ---- jSerialComm (cross-platform serial library with Android stubs) --------- - +# ---- jSerialComm Android stubs (cross-platform serial library) -------------- +# jSerialComm bundles Android shims that reference android.* classes; harmless +# on JVM/desktop but ProGuard fails the build on unresolved program classes +# unless suppressed. -dontwarn com.fazecast.jSerialComm.android.** -# ---- Kotlin stdlib atomics (Kotlin 2.3+ intrinsics, not on JDK 17) ---------- +# Wire ships AndroidMessage in its common runtime; on desktop classpath there is +# no android.os.Parcelable. We never use AndroidMessage on desktop. +-dontwarn com.squareup.wire.AndroidMessage +-dontwarn com.squareup.wire.AndroidMessage$* +-dontwarn android.os.Parcelable +-dontwarn android.os.Parcelable$* --dontwarn kotlin.concurrent.atomics.** --dontwarn kotlin.uuid.UuidV7Generator - -# ---- Library consumer rules ------------------------------------------------ -# The compose-jb gradle plugin auto-injects `default-compose-desktop-rules.pro` -# (bundled inside org.jetbrains.compose:compose-gradle-plugin) into every -# desktop ProGuard run. That file already covers: -# - kotlin.**, kotlinx.coroutines.** (incl. SwingDispatcherFactory ServiceLoader) -# - org.jetbrains.skiko.**, org.jetbrains.skia.** -# - kotlinx.serialization.** (incl. @Serializable companion keeps) -# - kotlinx.datetime.** -# - androidx.compose.runtime SnapshotStateKt + Material3 SliderDefaults -# So we DO NOT re-declare those here. Source of truth: -# https://github.com/JetBrains/compose-multiplatform/blob/master/gradle-plugins/compose/src/main/resources/default-compose-desktop-rules.pro -# -# However, the standalone ProGuard 7.7.0 that compose-jb invokes does NOT -# auto-import library `META-INF/proguard/*.pro` consumer rules from arbitrary -# jars (only R8/Android does). So any consumer-rule pattern outside the bundled -# defaults above must be copied here manually (see Ktor SL block below). - -# ---- androidx.sqlite bundled driver (JNI native bridge) --------------------- -# BundledSQLiteDriver loads `libsqliteJni` and the native code calls back into -# JVM-land via methods on `BundledSQLiteDriverKt` (e.g. `nativeThreadSafeMode`) -# and member methods on `BundledSQLiteDriver` itself. Because those JVM symbols -# are referenced only from native code, ProGuard removes them as unused; the -# native loader then crashes with `NoSuchMethodError: ... name or signature does -# not match`. Keep the whole driver package — it's small and entirely needed at -# runtime once the bundled SQLite driver is selected. --keep class androidx.sqlite.driver.bundled.** { *; } --keepclassmembers class androidx.sqlite.driver.bundled.** { native ; *; } - -# ---- Ktor serialization extension providers (ServiceLoader) ----------------- -# io.ktor.serialization.kotlinx-json discovers KotlinxSerializationJsonExtensionProvider -# via META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider. -# Without this keep the desktop HttpClient init throws ServiceConfigurationError -# at first request; on Windows jpackage's launcher swallows the trace and -# surfaces it as "Failed to launch JVM". --keep class * implements io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider { *; } - -# ---- Vico 3.2.0-next.1 ColorScale (CMP API drift) --------------------------- -# Vico's new ColorScale* classes (ColorScaleShader, ColorScaleAreaFill, -# ColorScaleLineFill) reference CMP UI graphics members that don't exist in -# compose-multiplatform 1.11.0-beta03 (LinearGradientShader-VjE6UOU$default -# on ShaderKt and Paint.setShader(org.jetbrains.skia.Shader)). We don't use -# the ColorScale APIs in this app, so suppress these warnings to let ProGuard -# proceed; otherwise it aborts with "unresolved program class members". -# Remove once Vico ships a release built against CMP 1.11 stable. +# Vico's ColorScale* classes call into skia-shader bridges that aren't on the +# desktop ProGuard classpath. Vico ships no consumer rules. -dontwarn com.patrykandpatrick.vico.compose.cartesian.ColorScaleShader -dontwarn com.patrykandpatrick.vico.compose.cartesian.layer.ColorScaleAreaFill -dontwarn com.patrykandpatrick.vico.compose.cartesian.layer.ColorScaleLineFill + +# ---- Kotlin 2.3+ stdlib intrinsics not present on JDK 17 -------------------- +-dontwarn kotlin.concurrent.atomics.** +-dontwarn kotlin.uuid.UuidV7Generator diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 550d9dc20..09818fc27 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,9 +44,9 @@ compose-multiplatform-material3 = "1.11.0-alpha07" # AndroidCompose.kt's resolutionStrategy force-aligns these groups to *this* version # at resolution time, so it is the source of truth for the Android target. androidx-compose-bom-aligned = "1.11.0" -# `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. +# `androidx-compose-material` (M2) is independent of CMP. Pinned because +# maps-compose-widgets requests `androidx.compose.material:material` without +# a version (relying on a BOM that we exclude). M2 is frozen at 1.7.8. androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha07" From e6f6369f497291ff01db056c36cd56b570e1e8db Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:26:17 -0500 Subject: [PATCH 63/65] chore(deps): update dd.sdk.android to v3.9.1 (#5237) 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 09818fc27..f22520a13 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,7 @@ aboutlibraries = "14.0.1" jserialcomm = "2.11.4" coil = "3.4.0" datadog-gradle = "1.25.0" -dd-sdk-android = "3.9.0" +dd-sdk-android = "3.9.1" detekt = "1.23.8" dokka = "2.2.0" devtools-ksp = "2.3.7" From 7ee929648cf1f589a50521360aec129212023291 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:43:11 -0500 Subject: [PATCH 64/65] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5238) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-cs/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index 713f7383e..b25a714a8 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -191,6 +191,7 @@ Vymazat protokoly Tímto odstraníte všechny logované pakety a záznamy databáze ze zařízení – jde o úplný reset a je nevratný. Vymazat + Hledat emoji... Kanál %1$s: %2$s Stav doručení zprávy @@ -213,10 +214,14 @@ Obnovit výchozí nastavení Použít Vzhled + Kontrast Světlý Tmavý Podle systému Vyberte vzhled + Úroveň kontrastu + Standardní + Střední Vysoká Poskytnout polohu síti Úsporné kódování pro cyriliku @@ -927,7 +932,12 @@ Tento QR kód obsahuje kompletní konfiguraci. Tímto se NAHRADÍ vaše stávající kanály a nastavení rádia. Všechny existující kanály budou odstraněny. Načítám + Filtr zpráv Zapnout filtrování + Skrýt zprávy obsahující filtrovaná slova + Filtrovat slova + Zprávy obsahující tato slova budou skryté + Nejsou nastavena žádná slova filtru Zobrazit %1$d filtrované Skrýt %1$d filtrované Filtrované @@ -968,10 +978,12 @@ Zelená Minimální interval pozice (v sekundách) Poznámka + Vzhled: %1$s, Jazyk: %2$s Připojit Hotovo Uživatelské jméno Hotovo Meshtastic Filtr + Reagovat s emoji
From 91f4a17b4829c908458fd2ff136fc38b0615a50d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:35:42 -0500 Subject: [PATCH 65/65] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5240) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../src/commonMain/composeResources/values-uk/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index e64a0ce85..6f9fc58ac 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -979,6 +979,12 @@ Аналітичні платформи: Для додаткової інформації, перегляньте нашу політику конфіденційності. Невстановлене - 0 + + Пройшло через %1$d вузол + Пройшло через %1$d вузлів + Пройшло через %1$d вузлів + Пройшло через %1$d вузли + %1$s зазвичай постачається із завантажувачем, який не підтримує оновлення OTA. Вам може знадобитися завантажувач з можливістю оновлень OTA через USB перед прошиванням OTA. Докладніше Не показувати знову для цього пристрою