From 84fe24467f98e637213823be284f3e04d943ee69 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 17 Apr 2026 23:11:32 -0500
Subject: [PATCH 01/10] fix(widget): drive updates via debounced state observer
(#5185)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../feature/widget/AndroidAppWidgetUpdater.kt | 36 ++++++++++++++++---
.../feature/widget/LocalStatsWidgetState.kt | 9 +----
.../widget/src/main/res/values/strings.xml | 20 +++++++++++
.../main/res/xml/widget_local_stats_info.xml | 1 +
4 files changed, 53 insertions(+), 13 deletions(-)
create mode 100644 feature/widget/src/main/res/values/strings.xml
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
index 415e0e11d..c6cef8aa3 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
@@ -17,22 +17,48 @@
package org.meshtastic.feature.widget
import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.updateAll
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AppWidgetUpdater
+private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L
+
@Single
-class AndroidAppWidgetUpdater(private val context: Context) : AppWidgetUpdater {
+class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) :
+ AppWidgetUpdater {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ init {
+ // Observe state changes and trigger a widget re-render whenever the data changes.
+ // Glance compositions are ephemeral — the widget cannot self-update via collectAsState()
+ // alone, so we must call updateAll() externally to drive re-renders.
+ @OptIn(FlowPreview::class)
+ scope.launch {
+ stateProvider.state
+ .debounce(WIDGET_UPDATE_DEBOUNCE_MS)
+ .distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) }
+ .collect { if (hasWidgetInstances()) updateAll() }
+ }
+ }
+
+ private suspend fun hasWidgetInstances(): Boolean =
+ GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty()
+
override suspend fun updateAll() {
- // Kickstart the widget composition.
- // The widget internally uses collectAsState() and its own sampled StateFlow
- // to drive updates automatically without excessive IPC and recreation.
@Suppress("TooGenericExceptionCaught")
try {
LocalStatsWidget().updateAll(context)
} catch (e: Exception) {
- co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" }
+ Logger.e(e) { "Failed to update widgets" }
}
}
}
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
index ee40bd60b..b8aca2664 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
@@ -76,8 +76,6 @@ data class LocalStatsWidgetUiState(
val updateTimeMillis: Long = 0,
)
-private const val WIDGET_SUBSCRIPTION_TIMEOUT_MS = 5_000L
-
@Single
class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -100,12 +98,7 @@ class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepos
.map { input ->
mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
}
- .distinctUntilChanged()
- .stateIn(
- scope = scope,
- started = SharingStarted.WhileSubscribed(WIDGET_SUBSCRIPTION_TIMEOUT_MS),
- initialValue = LocalStatsWidgetUiState(),
- )
+ .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState())
private data class StateInput(
val connectionState: ConnectionState,
diff --git a/feature/widget/src/main/res/values/strings.xml b/feature/widget/src/main/res/values/strings.xml
new file mode 100644
index 000000000..1e47c86ee
--- /dev/null
+++ b/feature/widget/src/main/res/values/strings.xml
@@ -0,0 +1,20 @@
+
+
+
+ Meshtastic
+
diff --git a/feature/widget/src/main/res/xml/widget_local_stats_info.xml b/feature/widget/src/main/res/xml/widget_local_stats_info.xml
index da9863cd9..6dde1ea1e 100644
--- a/feature/widget/src/main/res/xml/widget_local_stats_info.xml
+++ b/feature/widget/src/main/res/xml/widget_local_stats_info.xml
@@ -16,6 +16,7 @@
~ along with this program. If not, see .
-->
Date: Sat, 18 Apr 2026 07:09:22 -0500
Subject: [PATCH 02/10] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5186)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
.../composeResources/values-bg/strings.xml | 2 +
.../composeResources/values-de/strings.xml | 2 +
.../composeResources/values-et/strings.xml | 12 +++++
.../composeResources/values-fi/strings.xml | 23 ++++++++++
.../composeResources/values-fr/strings.xml | 2 +
.../composeResources/values-ru/strings.xml | 1 +
.../composeResources/values-sv/strings.xml | 2 +
.../composeResources/values-uk/strings.xml | 1 +
.../values-zh-rCN/strings.xml | 1 +
.../values-zh-rTW/strings.xml | 44 +++++++++++++++++++
10 files changed, 90 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index cdf34f2d3..ebf726c1c 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -488,6 +488,8 @@
Конфигуриране на MQTT
Прекъсната връзка
Свързано
+ Тестване на връзката
+ Връзката е неуспешна
MQTT е активиран
Адрес
Потребителско име
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index 8e97b008f..866eb8666 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -611,6 +611,8 @@
MQTT Einstellungen
Verbindung getrennt
Verbunden
+ Verbindung testen
+ Verbindung fehlgeschlagen
MQTT aktiviert
Adresse
Benutzername
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index be6376d0c..5cadd4b6b 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -1213,5 +1213,17 @@
Näita Meshtastic
Sule
Kärgvõrgustik
+ Ekspordi TAK andmepakett
+ Eemalda ajatsoon
Filtreeri
+ Eemalda filter
+ Näita õhukvaliteedi ajalugu
+ Kuva sõnumi olek
+ Saada vastus
+ Kopeeri sõnum
+ Vali sõnum
+ Kustuta sõnum
+ Vasta emotikoniga
+ Vali seade
+ Vali võrk
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index c3bc3dc9e..f9da71dea 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -611,9 +611,21 @@
MQTT asetukset
Passiivinen
Ei yhdistetty
+ Yhteys katkaistu — %1$s
Yhdistetään…
Yhdistetty
Yhdistetään uudelleen…
+ Yhdistetään uudelleen (yritys %1$d) — %2$s
+ Testaa yhteys
+ Tarkistetaan välityspalvelinta…
+ Yhteys onnistui. Välityspalvelin hyväksyi tunnistetiedot.
+ Yhteys onnistui (%1$s)
+ Välityspalvelin ei hyväksynyt: %1$s
+ Palvelinta ei löytynyt
+ Yhteyttä välityspalvelimeen ei saada (TCP)
+ TLS-yhteyden muodostus epäonnistui
+ Aikakatkaistu %1$d ms jälkeen
+ Yhdistäminen epäonnistui
MQTT käytössä
Osoite
Käyttäjänimi
@@ -1214,6 +1226,17 @@
Näytä Meshtastic
Lopeta
Meshtastic
+ Vie TAK-datapaketti
+ Tyhjennä aikavyöhyke
Suodatus
+ Poista suodatin
+ Näytä ilmanlaadun selite
+ Näytä viestin tila
+ Lähetä vastaus
+ Kopioi viesti
+ Valitse viesti
+ Poista viesti
+ Reaktio emojin kanssa
Valitse laite
+ Valitse verkko
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index b9c28e4cc..f4afeef5c 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -614,6 +614,8 @@
Connexion…
Connecté
Reconnexion…
+ Test de la connexion
+ Échec de la connexion
MQTT activé
Adresse
Nom d'utilisateur
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index cabfcd63f..430d8b802 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -619,6 +619,7 @@
Настройка MQTT
Отключено
Подключено
+ Проверить соединение
MQTT включен
Адрес
Имя пользователя
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index 7d81b8a8c..999213506 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -528,6 +528,8 @@
MQTT-konfiguration
Frånkopplad
Ansluten
+ Testa anslutningen
+ Anslutningen misslyckades
MQTT är aktiverat
Adress
Användarnamn
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index b2034aae1..c9a86af43 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -401,6 +401,7 @@
Налаштування MQTT
Відключено
Під’єднано
+ Перевірка зʼєднання
MQTT увімкнений
Адреса
Ім'я користувача
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index 992e58187..7fff0db20 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -567,6 +567,7 @@
MQTT设置
已断开连接
已连接
+ 连接测试
启用MQTT
地址
用户名
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index a6313dae7..20ee6c639 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -222,6 +222,11 @@
%1$s: %2$s
來自 %1$s 的訊息:%2$s
標頭
+ 標尾
+ 點形
+ 文字
+ 儀表板
+ 梯度
這是一個一個一個可客製化的組合元件
還支援多行文字與多種樣式
訊息傳遞狀態
@@ -407,6 +412,12 @@
回程跳數
來回跳數
無回應
+ 1分鐘負載
+ 5分鐘負載
+ 15分鐘負載
+ 1分鐘系統負載平均值
+ 5分鐘系統負載平均值
+ 15分鐘系統負載平均值
可用系統記憶體(位元組)
1小時
二十四小時
@@ -592,8 +603,23 @@
無視MQTT
允許轉發至 MQTT
MQTT配置
+ 已停用
已中斷連線
+ 已斷線 — %1$s
+ 正在連接…
已連線
+ 重新連接中…
+ 重新連接中(第 %1$d 次嘗試) — %2$s
+ 測試連線
+ 正在查詢 Broker…
+ 可供連線,Broker 已驗證並接受憑證。
+ 可供連線(%1$s)
+ Broker 遭拒:%1$s
+ 找不到伺服器
+ 無法連線至 Broker 中繼伺服器(TCP)
+ TLS 握手失敗
+ 經過 %1$d 毫秒後逾時
+ 測試失敗
啟用MQTT服務器
地址
用戶名
@@ -792,6 +818,9 @@
顯示路徑
顯示定位精準度
客户端通知
+ 金鑰驗證
+ 金鑰驗證請求
+ 金鑰驗證已完成
偵測到重複的公鑰
偵測到加密金鑰強度不足
偵測到金鑰已洩漏,點選確定後重新產生金鑰。
@@ -1160,6 +1189,8 @@
注意
裝置儲存空間與使用者介面(唯讀)
主題 %1$s,語言 %2$s
+ 可使用檔案(%1$d):
+ - %1$s(%2$d 位元)
未發現任何檔案。
連線
完成
@@ -1168,6 +1199,7 @@
進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS
正在搜尋裝置…
找到裝置
+ 準備好掃描 Wi-Fi 網路了。
搜尋網路
正在搜尋…
正在套用 Wi-Fi 設定…
@@ -1180,9 +1212,21 @@
手動輸入或選擇一個網路
Wi-Fi 已設定完成!
無法套用 Wi-Fi 設定
+ Meshtastic Desktop
顯示 Meshtastic
離開
Meshtastic
+ 匯出 TAK 資料封包
+ 清除時區
過濾器
+ 移除篩選條件
+ 顯示空氣品質圖例
+ 顯示訊息狀態
+ 傳送回覆
+ 複製訊息
+ 選擇訊息
+ 刪除訊息
+ 使用表情符號回應
選擇裝置
+ 選擇網路
From 2c1984ace5b852d5ab0df7facd4bf92734dc2f50 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Sun, 19 Apr 2026 11:30:34 -0500
Subject: [PATCH 03/10] chore(deps): update fastlane to v2.233.0 (#5190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Gemfile.lock | 46 ++++++++++++++++++++++------------------------
1 file changed, 22 insertions(+), 24 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index de497cc4a..cf6a1b9c0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
- addressable (2.8.8)
+ addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
- aws-partitions (1.1213.0)
- aws-sdk-core (3.242.0)
+ aws-partitions (1.1240.0)
+ aws-sdk-core (3.245.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,11 +17,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
- aws-sdk-kms (1.121.0)
- aws-sdk-core (~> 3, >= 3.241.4)
+ aws-sdk-kms (1.123.0)
+ aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.213.0)
- aws-sdk-core (~> 3, >= 3.241.4)
+ aws-sdk-s3 (1.219.0)
+ aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -29,7 +29,7 @@ GEM
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
- bigdecimal (4.0.1)
+ bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -68,11 +68,11 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
- faraday-retry (1.0.3)
+ faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
- fastimage (2.4.0)
- fastlane (2.232.2)
+ fastimage (2.4.1)
+ fastlane (2.233.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -92,7 +92,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
- fastlane-sirp (>= 1.0.0)
+ fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@@ -122,10 +122,9 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
- fastlane-sirp (1.0.0)
- sysrandom (~> 1.0)
+ fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
- google-apis-androidpublisher_v3 (0.95.0)
+ google-apis-androidpublisher_v3 (0.99.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@@ -139,15 +138,15 @@ GEM
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
- google-apis-storage_v1 (0.59.0)
+ google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
- google-cloud-errors (1.5.0)
- google-cloud-storage (1.58.0)
+ google-cloud-errors (1.6.0)
+ google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -169,13 +168,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
- json (2.18.1)
+ json (2.19.4)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
- multi_json (1.19.1)
+ multi_json (1.20.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -185,13 +184,13 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
- public_suffix (7.0.2)
- rake (13.3.1)
+ public_suffix (7.0.5)
+ rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
- retriable (3.1.2)
+ retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
@@ -205,7 +204,6 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
- sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
From 9dd57725f2d2687d40226e870eaa0490ef7531fb Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Sun, 19 Apr 2026 12:31:11 -0500
Subject: [PATCH 04/10] chore(deps): update vico to v3.2.0-next.1 (#5191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 79436cd48..baf89fb1d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -78,7 +78,7 @@ uri-kmp = "0.0.21"
osmdroid-android = "6.1.20"
spotless = "8.4.0"
wire = "6.2.0"
-vico = "3.1.0"
+vico = "3.2.0-next.1"
kable = "0.42.0"
mqttastic = "0.2.0"
jmdns = "3.6.3"
From 99e7407a90a4e3267a01e2aefdf4b4bc2a6b3e12 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sun, 19 Apr 2026 15:07:52 -0500
Subject: [PATCH 05/10] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5189)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
.../composeResources/values-de/strings.xml | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index 866eb8666..4755515ad 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -609,9 +609,22 @@
MQTT ignorieren
OK für MQTT
MQTT Einstellungen
+ Inaktiv
Verbindung getrennt
+ Verbindung getrennt - %1$s
+ Wird verbunden
Verbunden
+ Erneut verbinden
+ Erneut verbinden (Versuch %1$d) - %2$s
Verbindung testen
+ Broker prüfen.
+ Erreichbar. Broker akzeptierte Anmeldedaten.
+ Erreichbar (%1$s)
+ Broker abgelehnt: %1$s
+ Host nicht gefunden
+ Broker (TCP) nicht erreichbar
+ TLS Handshake fehlgeschlagen
+ Zeitüberschreitung nach %1$d ms
Verbindung fehlgeschlagen
MQTT aktiviert
Adresse
@@ -1208,7 +1221,21 @@
Netzwerk eingeben oder auswählen
WLAN erfolgreich konfiguriert!
WLAN Konfiguration konnte nicht angewendet werden
+ Meshtastic Desktop
+ Meshtastic anzeigen
+ Beenden
Meshtastic
+ TAK Datenpaket exportieren
+ Zeitzone löschen
Filter
+ Filter entfernen
+ Legende für Luftqualität anzeigen
+ Nachrichtenstatus anzeigen
+ Antwort senden
+ Nachricht kopieren
+ Nachricht auswählen
+ Nachricht löschen
+ Mit Emoji reagieren
Gerät auswählen
+ Wählen Sie ein Netzwerk
From 3322257cfddc72f4a1b5f3b59910e948bb37ce91 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 20 Apr 2026 06:47:09 -0500
Subject: [PATCH 06/10] chore(deps): update plugin com.gradle.develocity to
v4.4.1 (#5194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
build-logic/settings.gradle.kts | 2 +-
settings.gradle.kts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
index 2fa797c74..91b8ebce2 100644
--- a/build-logic/settings.gradle.kts
+++ b/build-logic/settings.gradle.kts
@@ -30,7 +30,7 @@ pluginManagement {
}
plugins {
- id("com.gradle.develocity") version("4.4.0")
+ id("com.gradle.develocity") version("4.4.1")
}
dependencyResolutionManagement {
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f9664baaa..445d1cfac 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -83,7 +83,7 @@ dependencyResolutionManagement {
plugins {
id("org.gradle.toolchains.foojay-resolver") version "1.0.0"
- id("com.gradle.develocity") version("4.4.0")
+ id("com.gradle.develocity") version("4.4.1")
id("com.gradle.common-custom-user-data-gradle-plugin") version "2.6.0"
}
From 2b47da3b61421476c0d853f83300186ff8e1c59d Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 20 Apr 2026 07:40:08 -0500
Subject: [PATCH 07/10] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5193)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
.../composeResources/values-et/strings.xml | 12 ++++++++
.../composeResources/values-ru/strings.xml | 28 +++++++++++++++++++
.../composeResources/values-sv/strings.xml | 2 ++
3 files changed, 42 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 5cadd4b6b..c2e327629 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -611,9 +611,21 @@
MQTT sätted
Mitteaktiivne
Ühendus katkenud
+ Ühendus katkenud — %1$s
Ühendan…
Ühendatud
Taas ühendan…
+ Ühendan uuesti (katse %1$d) — %2$s
+ Test ühendus
+ Kontrollin vahendajat…
+ Ühendus õnnestus. Vahendaja aktsepteeris kasutajateave.
+ Kättesaadav (%1$s)
+ Vahendaja lükkas tagasi: %1$s
+ Hosti ei leitud
+ Vahendajaga ei saa ühendust (TCP)
+ TLS ühendus ebaõnnestus
+ Ajaline katkestus peale %1$d ms
+ Ühendus ebaõnnestus
MQTT lubatud
Aadress
Kasutajatunnus
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index 430d8b802..8d4590e82 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -617,9 +617,23 @@
Игнорировать MQTT
ОК в MQTT
Настройка MQTT
+ Неактивно
Отключено
+ Отключено — %1$s
+ Подключение...
Подключено
+ Переподключение...
+ Переподключение (попытка %1$d) — %2$s
Проверить соединение
+ Проверяем брокер…
+ Доступно. Брокер принял учетные данные.
+ Доступно (%1$s)
+ Брокер отклонен: %1$s
+ Узел не найден
+ Не удается подключиться к брокеру (TCP)
+ Сбой TLS-рукопожатия
+ Тайм-аут после %1$d мс
+ Соединение не удалось
MQTT включен
Адрес
Имя пользователя
@@ -1223,7 +1237,21 @@
Введите или выберите сеть
Wi-Fi успешно настроен!
Не удалось применить настройку Wi-Fi
+ Meshtastic Desktop
+ Показать Meshtastic
+ Выход
Meshtastic
+ Экспорт пакета данных TAK
+ Очистить часовой пояс
Фильтр
+ Удалить фильтр
+ Показать легенду качества воздуха
+ Показать статус сообщения
+ Отправить ответ
+ Скопировать сообщение
+ Выбрать сообщение
+ Удалить сообщение
+ Отреагировать эмодзи
Выберите устройство
+ Выбрать сеть
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index 999213506..59e19f1e5 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -43,6 +43,7 @@
Okänd
Inväntar kvittens
Kvittens köad
+ Levererad till nät
Okänd
Kvitterad
Ingen rutt
@@ -370,6 +371,7 @@
Varaktighet: %1$s s
Rutt spårad mot destination:\n\n
Rutten spårad tillbaka till oss:\n\n
+ Inget svar
1h
24T
1V
From 7492a33cf8cc0b1f67489d540a6ea322466508e7 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Mon, 20 Apr 2026 10:59:20 -0500
Subject: [PATCH 08/10] Fix node-details remove action to preserve confirmation
flow (#5192)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: James Rich
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../feature/node/detail/HandleNodeAction.kt | 5 +-
.../node/detail/NodeDetailViewModel.kt | 5 +-
.../node/detail/NodeManagementActions.kt | 7 +-
.../node/detail/HandleNodeActionTest.kt | 90 +++++++++++++++++++
.../node/detail/NodeManagementActionsTest.kt | 20 +++++
5 files changed, 119 insertions(+), 8 deletions(-)
create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt
index 9ce025604..559582417 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt
@@ -43,10 +43,7 @@ internal fun handleNodeAction(
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
- is NodeMenuAction.Remove -> {
- viewModel.handleNodeMenuAction(menuAction)
- onNavigateUp()
- }
+ is NodeMenuAction.Remove -> viewModel.handleNodeMenuAction(menuAction, onNavigateUp)
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
index 733cd858c..e891d8ae0 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
@@ -89,9 +89,10 @@ class NodeDetailViewModel(
}
/** Dispatches high-level node management actions like removal, muting, or favoriting. */
- fun handleNodeMenuAction(action: NodeMenuAction) {
+ fun handleNodeMenuAction(action: NodeMenuAction, onAfterRemove: () -> Unit = {}) {
when (action) {
- is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node)
+ is NodeMenuAction.Remove ->
+ nodeManagementActions.requestRemoveNode(viewModelScope, action.node, onAfterRemove)
is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
index 436954201..9c021e666 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
@@ -50,11 +50,14 @@ constructor(
private val radioController: RadioController,
private val alertManager: AlertManager,
) {
- open fun requestRemoveNode(scope: CoroutineScope, node: Node) {
+ open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) {
alertManager.showAlert(
titleRes = Res.string.remove,
messageRes = Res.string.remove_node_text,
- onConfirm = { removeNode(scope, node.num) },
+ onConfirm = {
+ removeNode(scope, node.num)
+ onAfterRemove()
+ },
)
}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt
new file mode 100644
index 000000000..6bca8822b
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.detail
+
+import androidx.lifecycle.SavedStateHandle
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import dev.mokkery.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.feature.node.component.NodeMenuAction
+import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
+import org.meshtastic.feature.node.model.NodeDetailAction
+import org.meshtastic.proto.User
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertFalse
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class HandleNodeActionTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val nodeManagementActions: NodeManagementActions = mock()
+ private val nodeRequestActions: NodeRequestActions = mock()
+ private val serviceRepository: ServiceRepository = mock()
+ private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ every { getNodeDetailsUseCase(any()) } returns emptyFlow()
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `remove action delegates to viewModel and does not navigate up immediately`() = runTest(testDispatcher) {
+ val node = Node(num = 1234, user = User(id = "!1234"))
+ every { nodeManagementActions.requestRemoveNode(any(), any(), any()) } returns Unit
+ val viewModel = createViewModel()
+ var navigateUpCalled = false
+
+ handleNodeAction(
+ action = NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node)),
+ uiState = NodeDetailUiState(),
+ navigateToMessages = {},
+ onNavigateUp = { navigateUpCalled = true },
+ onNavigate = {},
+ viewModel = viewModel,
+ )
+
+ verify { nodeManagementActions.requestRemoveNode(any(), node, any()) }
+ assertFalse(navigateUpCalled)
+ }
+
+ private fun createViewModel() = NodeDetailViewModel(
+ savedStateHandle = SavedStateHandle(mapOf("destNum" to 1234)),
+ nodeManagementActions = nodeManagementActions,
+ nodeRequestActions = nodeRequestActions,
+ serviceRepository = serviceRepository,
+ getNodeDetailsUseCase = getNodeDetailsUseCase,
+ )
+}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
index 89015c807..3212a313e 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
@@ -30,6 +30,7 @@ import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.proto.User
import kotlin.test.Test
+import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class NodeManagementActionsTest {
@@ -69,4 +70,23 @@ class NodeManagementActionsTest {
)
}
}
+
+ @Test
+ fun requestRemoveNode_invokes_onAfterRemove_when_user_confirms() {
+ val realAlertManager = AlertManager()
+ val actionsWithRealAlert =
+ NodeManagementActions(
+ nodeRepository = nodeRepository,
+ serviceRepository = serviceRepository,
+ radioController = radioController,
+ alertManager = realAlertManager,
+ )
+ val node = Node(num = 123, user = User(long_name = "Test Node"))
+ var afterRemoveCalled = false
+
+ actionsWithRealAlert.requestRemoveNode(testScope, node) { afterRemoveCalled = true }
+ realAlertManager.currentAlert.value?.onConfirm?.invoke()
+
+ assertTrue(afterRemoveCalled)
+ }
}
From a90cb2d89e136020a1465edc8281fbf9396270ac Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 20 Apr 2026 12:32:58 -0500
Subject: [PATCH 09/10] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#5195)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
---
.../src/commonMain/composeResources/values-bg/strings.xml | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index ebf726c1c..f69e137d9 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -486,9 +486,16 @@
Честотен слот
Игнориране на MQTT
Конфигуриране на MQTT
+ Неактивен
Прекъсната връзка
+ Свързване…
Свързано
+ Повторно свързване…
+ Повторно свързване (опит %1$d) — %2$s
Тестване на връзката
+ Достъпен. Брокерът е приел идентификационните данни.
+ Достъпен (%1$s)
+ Хостът не е намерен
Връзката е неуспешна
MQTT е активиран
Адрес
@@ -971,4 +978,5 @@
Meshtastic
Филтър
Изберете устройство
+ Изберете мрежа
From f21d8af9aeb29b3e67f5e3119f6cdecd2d003ad1 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 20 Apr 2026 12:34:16 -0500
Subject: [PATCH 10/10] fix(transport): improve BLE / TCP / USB reconnect and
handshake resilience (#5196)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.gitignore | 1 +
.../kotlin/org/meshtastic/app/MainActivity.kt | 18 +++++++++
.../core/ble/BleExceptionClassifier.kt | 9 ++++-
.../data/manager/MeshConnectionManagerImpl.kt | 18 ++++++---
.../network/radio/SerialRadioTransport.kt | 5 ++-
.../repository/SerialConnectionImpl.kt | 5 +++
.../core/network/repository/UsbRepository.kt | 10 ++---
.../core/network/radio/BleRadioTransport.kt | 6 ++-
.../core/network/radio/BleReconnectPolicy.kt | 20 ++++++++--
.../core/network/radio/StreamTransport.kt | 12 +++---
.../network/radio/BleRadioTransportTest.kt | 38 ++++++++++---------
.../core/network/radio/TcpRadioTransport.kt | 6 ++-
.../core/network/SerialTransport.kt | 13 +++++--
.../AndroidGetDiscoveredDevicesUseCase.kt | 9 ++++-
14 files changed, 124 insertions(+), 46 deletions(-)
diff --git a/.gitignore b/.gitignore
index 8447bc7f7..447d8a28e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,4 @@ wireless-install.sh
firebase-debug.log
.agent_plans/
.agent_refs/
+.agent_artifacts/
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 0864e55cd..628865010 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -59,6 +59,7 @@ import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
@@ -91,6 +92,8 @@ import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
+ private val usbRepository: UsbRepository by inject()
+
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
@@ -166,6 +169,16 @@ class MainActivity : ComponentActivity() {
handleIntent(intent)
}
+ override fun onResume() {
+ super.onResume()
+ // Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
+ // resumed while a USB device is already attached (e.g. process restart, returning
+ // from another app), the manifest-declared attach intent may have already fired
+ // before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
+ // reality without requiring the user to physically replug.
+ usbRepository.refreshState()
+ }
+
@Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) {
CompositionLocalProvider(
@@ -257,6 +270,11 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Logger.d { "USB device attached" }
+ // Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
+ // receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
+ // never sees this event. Forward it explicitly so the serialDevices StateFlow
+ // refreshes and the device shows up in the Connect → Serial tab.
+ usbRepository.refreshState()
showSettingsPage()
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
index 6f5180b60..d273a0b90 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
@@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException
/**
* Classification of a BLE-layer exception for the transport layer to act on.
*
- * @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled).
+ * @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device.
+ * Currently always `false` — all known BLE exceptions can resolve without user intervention (BT toggling, permission
+ * grants, transient GATT errors). Reserved for future use.
* @property gattStatus the platform GATT status code when available (Android-specific).
* @property message a human-readable description of the failure.
*/
@@ -50,6 +52,9 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
is GattRequestRejectedException ->
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
is UnmetRequirementException ->
- BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable")
+ // Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the
+ // device (user re-enables BT, or grants permission). Surface as transient so the transport keeps
+ // retrying; UI can show a hint based on the message.
+ BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable")
else -> null
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index a60dc85c5..022f3548d 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -60,6 +60,7 @@ import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
@@ -211,11 +212,11 @@ class MeshConnectionManagerImpl(
}
}
- private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
+ private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
handshakeTimeout?.cancel()
handshakeTimeout =
scope.handledLaunch {
- delay(HANDSHAKE_TIMEOUT)
+ delay(timeout)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
@@ -291,13 +292,13 @@ class MeshConnectionManagerImpl(
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
- startHandshakeStallGuard(1, action)
+ startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
- startHandshakeStallGuard(2, action)
+ startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
action()
}
@@ -404,7 +405,14 @@ class MeshConnectionManagerImpl(
*/
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
- private val HANDSHAKE_TIMEOUT = 30.seconds
+ private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
+
+ /**
+ * Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
+ * 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
+ * nodes.
+ */
+ private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
// Shorter window for the retry attempt: if the device genuinely didn't receive the
// first want_config_id the retry completes within a few seconds. Waiting another 30s
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
index bc3558800..0f7985276 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
@@ -108,7 +108,10 @@ class SerialRadioTransport(
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes)"
}
- onDeviceDisconnect(false)
+ // USB unplug / cable error is transient — the transport will reconnect when
+ // the device is replugged or the OS re-enumerates the port. Only an explicit
+ // close() (user disconnects) should signal a permanent disconnect.
+ onDeviceDisconnect(waitForStopped = false, isPermanent = false)
}
},
)
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
index b2ccf6545..d8b14be03 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
@@ -87,6 +87,11 @@ internal class SerialConnectionImpl(
port.open(usbDeviceConnection)
port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
+
+ // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as
+ // present and starts its serial-side Meshtastic protocol. Empirically, omitting these
+ // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at
+ // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion.
port.dtr = true
port.rts = true
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
index b4773dff3..c5080ec14 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
@@ -54,9 +54,7 @@ class UsbRepository(
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
- buildMap {
- serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
- }
+ buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@@ -83,6 +81,8 @@ class UsbRepository(
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
}
- private suspend fun refreshStateInternal() =
- withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
+ private suspend fun refreshStateInternal() = withContext(dispatchers.default) {
+ val devices = usbManagerLazy.value?.deviceList ?: emptyMap()
+ _serialDevices.emit(devices)
+ }
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
index 77114ff55..f2ba25804 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
@@ -133,7 +133,11 @@ class BleRadioTransport(
@Volatile private var isFullyConnected = false
private var connectionJob: Job? = null
- private val reconnectPolicy = BleReconnectPolicy()
+
+ // Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
+ // own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
+ // toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
+ private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE)
private val heartbeatSender =
HeartbeatSender(
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
index cef746af0..e4d250796 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
@@ -26,10 +26,11 @@ import kotlin.time.Duration.Companion.seconds
/**
* Encapsulates the BLE reconnection policy with exponential backoff.
*
- * The policy tracks consecutive failures and decides whether to retry, signal a transient disconnect (DeviceSleep), or
- * give up permanently.
+ * The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep).
+ * When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns;
+ * set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely.
*
- * @param maxFailures maximum consecutive failures before giving up permanently
+ * @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely
* @param failureThreshold after this many consecutive failures, signal a transient disconnect
* @param settleDelay delay before each connection attempt to let the BLE stack settle
* @param minStableConnection minimum time a connection must stay up to be considered "stable"
@@ -148,7 +149,18 @@ class BleReconnectPolicy(
companion object {
const val DEFAULT_MAX_FAILURES = 10
const val DEFAULT_FAILURE_THRESHOLD = 3
- val DEFAULT_SETTLE_DELAY = 1.seconds
+
+ /**
+ * Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side
+ * GATT session have time to settle.
+ *
+ * Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between
+ * disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the
+ * firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose
+ * to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more
+ * mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same.
+ */
+ val DEFAULT_SETTLE_DELAY = 3.seconds
val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds
internal val RECONNECT_BASE_DELAY = 5.seconds
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
index ac912346a..8c689dbcb 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
@@ -37,18 +37,20 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p
override suspend fun close() {
Logger.d { "Closing stream for good" }
- onDeviceDisconnect(true)
+ onDeviceDisconnect(waitForStopped = true, isPermanent = true)
}
/**
- * Notify the transport callback that our device has gone away, but wait for it to come back.
+ * Signals the transport callback that the device has disconnected and optionally waits for the transport to stop.
*
* @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside
* transport callbacks
- * @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g.
- * TCP transient disconnect). Defaults to true for serial — subclasses may override with false.
+ * @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O
+ * errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS
+ * re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to
+ * signal a user-initiated terminal disconnect.
*/
- protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) {
+ protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) {
callback.onDisconnect(isPermanent = isPermanent)
}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
index f1049f897..840dc214a 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
@@ -22,6 +22,7 @@ import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
@@ -95,10 +96,10 @@ class BleRadioTransportTest {
* [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep
* timeout in [MeshConnectionManagerImpl]).
*
- * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses,
- * connectAndAwait throws, backoff 5 s starts t = 6 000 ms — backoff ends t = 7 000 ms — iteration 2 settle delay
- * elapses, connectAndAwait throws, backoff 10 s starts t = 17 000 ms — backoff ends t = 18 000 ms — iteration 3
- * settle delay elapses, connectAndAwait throws → onDisconnect called
+ * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1
+ * settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms —
+ * iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24
+ * 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called
*/
@Test
fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest {
@@ -119,10 +120,10 @@ class BleRadioTransportTest {
)
bleTransport.start()
- // Advance through exactly 3 failure iterations (≈18 001 ms virtual time).
+ // Advance through exactly 3 failure iterations (≈24 001 ms virtual time).
// The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended
// and advanceTimeBy returns cleanly.
- advanceTimeBy(18_001L)
+ advanceTimeBy(24_001L)
verify { service.onDisconnect(any(), any()) }
@@ -131,16 +132,17 @@ class BleRadioTransportTest {
}
/**
- * After [BleReconnectPolicy.DEFAULT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and
- * signal a permanent disconnect. This prevents infinite battery drain when the device is genuinely offline.
+ * Reconnect policy must NEVER give up on its own. The transport is only ever instantiated for the user-selected
+ * device, and explicit-disconnect is owned by the service layer (close()). Even after a sustained failure storm —
+ * well beyond the legacy [BleReconnectPolicy.DEFAULT_MAX_FAILURES] — the transport must keep retrying and must
+ * never call `onDisconnect(isPermanent = true)` from the give-up path.
*
- * Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw +
- * backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s
- * settle + 5+10+20+40+60+60+60+60+60 = 10 + 375 = 385s ≈ 385_000ms We use a generous 400_000ms to cover any timing
- * variance.
+ * Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw +
+ * backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s
+ * settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance.
*/
@Test
- fun `reconnect loop stops after DEFAULT_MAX_FAILURES with permanent disconnect`() = runTest {
+ fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest {
val device = FakeBleDevice(address = address, name = "Test Device")
bluetoothRepository.bond(device)
@@ -158,11 +160,13 @@ class BleRadioTransportTest {
)
bleTransport.start()
- // Advance enough time for all 10 failures to occur.
- advanceTimeBy(400_001L)
+ // Run well past where the legacy policy (maxFailures = 10) would have given up.
+ advanceTimeBy(800_001L)
- // Should have been called with isPermanent=true at least once (the final call).
- verify { service.onDisconnect(isPermanent = true, errorMessage = any()) }
+ // Transient disconnects (isPermanent = false) are expected once the failure threshold is hit;
+ // the policy must NEVER signal a permanent disconnect on its own. Only explicit close()
+ // (verified separately by the service layer) may emit isPermanent = true.
+ verify(mode = VerifyMode.not) { service.onDisconnect(isPermanent = true, errorMessage = any()) }
bleTransport.close()
}
diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
index 354c4cd30..202d8de57 100644
--- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
+++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
@@ -78,7 +78,11 @@ open class TcpRadioTransport(
Logger.d { "[$address] Closing TCP transport" }
closing = true
transport.stop()
- callback.onDisconnect(isPermanent = true)
+ // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the
+ // service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting
+ // it from close() caused a double-disconnect and prevented the auto-reconnect loop from
+ // owning its own lifecycle. The `closing` guard above suppresses the listener's transient
+ // disconnect during teardown.
}
override fun keepAlive() {
diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
index a3f34d67e..45ba70eb7 100644
--- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
@@ -129,7 +129,10 @@ private constructor(
// Ignore errors during port close
}
if (isActive) {
- onDeviceDisconnect(true)
+ // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as
+ // transient — the user did not explicitly disconnect, and the port may come
+ // back when the device is replugged or the OS re-enumerates it.
+ onDeviceDisconnect(waitForStopped = true, isPermanent = false)
}
}
}
@@ -169,8 +172,10 @@ private constructor(
private const val READ_TIMEOUT_MS = 100
/**
- * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent
- * disconnect to the [callback] and returns the (non-connected) instance.
+ * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient
+ * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as
+ * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the
+ * user grants permission); only an explicit close should signal a permanent disconnect.
*/
fun open(
portName: String,
@@ -183,7 +188,7 @@ private constructor(
if (!transport.startConnection()) {
val errorMessage = diagnoseOpenFailure(portName)
Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
- callback.onDisconnect(isPermanent = true, errorMessage = errorMessage)
+ callback.onDisconnect(isPermanent = false, errorMessage = errorMessage)
}
return transport
}
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
index b0a3d738c..b6999aadc 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
@@ -60,7 +60,14 @@ class AndroidGetDiscoveredDevicesUseCase(
override fun invoke(showMock: Boolean): Flow {
val nodeDb = nodeRepository.nodeDBbyNum
- val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
+ // Filter out non-Meshtastic peripherals (headphones, cars, watches, etc.).
+ // BluetoothAdapter.bondedDevices returns every bonded device on the phone, so we
+ // must restrict the picker to entries whose advertised name matches the
+ // Meshtastic firmware pattern (see MeshtasticBleConstants.BLE_NAME_PATTERN).
+ val bondedBleFlow =
+ bluetoothRepository.state.map { ble ->
+ ble.bondedDevices.filter { it.getMeshtasticShortName() != null }.map { DeviceListEntry.Ble(it) }
+ }
val processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {