From 65b885a073b7faa65d562ad01e01b3f111c6b9e7 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 16 Apr 2026 21:41:36 -0500
Subject: [PATCH 01/30] chore(deps): update core/proto/src/main/proto digest to
4d5b500 (#5161)
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 c9067dae4..4d5b500df 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit c9067dae4a540d75a0daf3fa4d9be89f5918124d
+Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c
From a6a889430b63f9d9a579308b47e1deddf055cc9e Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 16 Apr 2026 21:43:35 -0500
Subject: [PATCH 02/30] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5159)
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 | 1 +
.../src/commonMain/composeResources/values-ca/strings.xml | 1 +
.../src/commonMain/composeResources/values-cs/strings.xml | 1 +
.../src/commonMain/composeResources/values-de/strings.xml | 1 +
.../src/commonMain/composeResources/values-es/strings.xml | 1 +
.../src/commonMain/composeResources/values-et/strings.xml | 1 +
.../src/commonMain/composeResources/values-fi/strings.xml | 1 +
.../src/commonMain/composeResources/values-fr/strings.xml | 1 +
.../src/commonMain/composeResources/values-hr/strings.xml | 1 +
.../src/commonMain/composeResources/values-hu/strings.xml | 1 +
.../src/commonMain/composeResources/values-it/strings.xml | 1 +
.../src/commonMain/composeResources/values-ja/strings.xml | 1 +
.../src/commonMain/composeResources/values-ko/strings.xml | 1 +
.../src/commonMain/composeResources/values-pl/strings.xml | 1 +
.../src/commonMain/composeResources/values-pt-rBR/strings.xml | 1 +
.../src/commonMain/composeResources/values-pt/strings.xml | 1 +
.../src/commonMain/composeResources/values-ro/strings.xml | 1 +
.../src/commonMain/composeResources/values-ru/strings.xml | 1 +
.../src/commonMain/composeResources/values-sk/strings.xml | 1 +
.../src/commonMain/composeResources/values-sv/strings.xml | 1 +
.../src/commonMain/composeResources/values-tr/strings.xml | 1 +
.../src/commonMain/composeResources/values-uk/strings.xml | 1 +
.../src/commonMain/composeResources/values-zh-rCN/strings.xml | 1 +
.../src/commonMain/composeResources/values-zh-rTW/strings.xml | 1 +
25 files changed, 25 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml
index aee9e7120..301ff3bb4 100644
--- a/core/resources/src/commonMain/composeResources/values-be/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml
@@ -219,4 +219,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 e12d5506c..fe1520458 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -960,4 +960,5 @@
Въведете или изберете мрежа
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 7874fbf89..485e1c9e1 100644
--- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml
@@ -199,4 +199,5 @@
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
index a0ccafea5..6220218ac 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -968,4 +968,5 @@
Poznámka
Připojit
Hotovo
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index 141e717b2..161feaa3e 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -1208,4 +1208,5 @@
Netzwerk eingeben oder auswählen
WLAN erfolgreich konfiguriert!
WLAN Konfiguration konnte nicht angewendet werden
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml
index 7b9ca263e..3470f7bed 100644
--- a/core/resources/src/commonMain/composeResources/values-es/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml
@@ -836,4 +836,5 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Verde
Conectar
Hecho
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 651eb5d2f..650c69122 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -1208,4 +1208,5 @@
Sisestage või valige võrk
WiFi edukalt seadistatud!
WiFi sätete rakendamine ebaõnnestus
+ 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 3fdd6afaf..3d9e28e91 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -1209,4 +1209,5 @@
Syötä tai valitse verkko
WiFi määritetty onnistuneesti!
WiFi-asetusten käyttöönotto epäonnistui
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index af88601ba..0ef821cb6 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -1046,4 +1046,5 @@
Module activé
Connecter
Terminé
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
index f049338ae..aae7d6690 100644
--- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
@@ -167,4 +167,5 @@
Crveno
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
index c8d27cf4a..f553a6a32 100644
--- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
@@ -850,4 +850,5 @@
Kék
Zöld
Csatlakozás
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 350db6cb2..744741047 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -960,4 +960,5 @@
Note
Connetti
Fatto
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 490a0a2ec..59b54d2f5 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -652,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 0ba6232b9..0a5bc4031 100644
--- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
@@ -539,4 +539,5 @@
파랑
초록
연결
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
index 394a20bd3..272f515f8 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -749,4 +749,5 @@
Moduł Włączony
Połącz
Wykonano
+ Meshtastic
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 7e753eefc..521a84b48 100644
--- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
@@ -665,4 +665,5 @@
Azul
Verde
Concluído
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
index 0cc07b820..545bd4e6f 100644
--- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
@@ -515,4 +515,5 @@
Azul
Verde
Ligar
+ Nome do nó de alternativo
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index 7cf64363b..984a939d8 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -1168,4 +1168,5 @@
Introdu sau selecteaza o retea
WiFi configurat cu succes!
Nu s-a reușit aplicarea configurației Wi-Fi
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index fd964e294..dd0d4a53f 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -1224,4 +1224,5 @@
Введите или выберите сеть
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 257154144..e51ef506d 100644
--- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
@@ -427,4 +427,5 @@
Červená
Modrá
Zelená
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index fce685c0a..27f368d7e 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -945,4 +945,5 @@
Modul aktiverad
Anslut
Klart
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
index cbd1be2ae..a3ae53c8c 100644
--- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
@@ -546,4 +546,5 @@
Mavi
Yeşil
Bağlan
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index e92552e55..3e96490dc 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -721,4 +721,5 @@
Зелений
Під’єднатися
Готово
+ 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 9d6e0fbf6..87feeb9e2 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -1114,4 +1114,5 @@
备注
连接
完成
+ 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 72ab1373a..354415089 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -1171,4 +1171,5 @@
%1$d%
可用的網路
網路名稱(SSID)
+ Meshtastic
From df3b5365f9b69a20ba2cf4ea453fae7278f34f23 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 16 Apr 2026 21:40:17 -0500
Subject: [PATCH 03/30] fix(node): don't recreate Vico
CartesianChartModelProducer on channel switch (#5160)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../meshtastic/feature/node/metrics/BaseMetricChart.kt | 10 ++++++----
.../meshtastic/feature/node/metrics/PowerMetrics.kt | 10 ++++------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
index a425e272d..88f4d1d6d 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt
@@ -159,26 +159,28 @@ fun GenericMetricChart(
*
* @param isEmpty Whether the chart data is empty — when true, nothing is rendered.
* @param legendData Legend items shown below the chart.
- * @param key Optional key for the [CartesianChartModelProducer] (e.g. a selected channel). Pass a different value to
- * recreate the producer.
* @param hiddenSet Indices of hidden legend items (toggleable legend).
* @param onToggle Callback when a legend item is toggled; when null, a read-only legend is rendered.
* @param content Builder lambda receiving the [CartesianChartModelProducer] and a standard `Modifier.weight(1f)`
* suitable for the chart area.
+ *
+ * A single [CartesianChartModelProducer] is created per scaffold instance. Vico forbids swapping the producer attached
+ * to a live [CartesianChartHost] (it throws "A new `CartesianChartModelProducer` was provided…"), so callers must push
+ * new data through [CartesianChartModelProducer.runTransaction] instead of recreating the producer. Keying the scaffold
+ * on external state (e.g. a selected channel) caused exactly that crash, so the previous `key` parameter was removed.
*/
@Composable
fun MetricChartScaffold(
isEmpty: Boolean,
legendData: List,
modifier: Modifier = Modifier,
- key: Any? = Unit,
hiddenSet: Set = emptySet(),
onToggle: ((Int) -> Unit)? = null,
content: @Composable ColumnScope.(CartesianChartModelProducer, Modifier) -> Unit,
) {
Column(modifier = modifier) {
if (isEmpty) return@Column
- val modelProducer = remember(key) { CartesianChartModelProducer() }
+ val modelProducer = remember { CartesianChartModelProducer() }
val chartModifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp)
content(modelProducer, chartModifier)
Legend(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
index 5e7560bcb..5a71659f8 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt
@@ -182,12 +182,10 @@ private fun PowerMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- MetricChartScaffold(
- isEmpty = telemetries.isEmpty(),
- legendData = LEGEND_DATA,
- modifier = modifier,
- key = selectedChannel,
- ) { modelProducer, chartModifier ->
+ MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) {
+ modelProducer,
+ chartModifier,
+ ->
val currentColor = PowerMetric.CURRENT.color
val voltageColor = PowerMetric.VOLTAGE.color
val marker =
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 04/30] 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 05/30] 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 06/30] 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 07/30] 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 08/30] 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