From 9ac02cf851c4467e1bc897e40f08f570aaf7df7f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:45:27 -0500 Subject: [PATCH 01/38] fix(app): disable R8 optimization to fix Compose animation freeze (#5150) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/proguard-rules.pro | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8340fdd10..d443e173a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,18 +1,31 @@ # ============================================================================ # Meshtastic Android — ProGuard / R8 rules for release minification # ============================================================================ -# Open-source project: obfuscation is disabled. We rely on tree-shaking and -# code optimization for APK size reduction. +# Open-source project: obfuscation and optimization are disabled. We rely on +# tree-shaking (unused code removal) for APK size reduction. # ============================================================================ # ---- General ---------------------------------------------------------------- -# Preserve line numbers for meaningful crash stack traces +# Preserve line numbers for meaningful crash traces -keepattributes SourceFile,LineNumberTable # Open-source — no need to obfuscate -dontobfuscate +# Disable R8 optimization passes. Tree-shaking (unused code removal) still +# runs — only method-body rewrites and call-site transformations are suppressed. +# +# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on +# Composer.() and ComposerImpl.(), plus -assumevalues on +# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives +# let R8 rewrite *call sites* (class-init triggers, flag reads) even when the +# target classes are preserved by -keep rules. The result is that the Compose +# recomposer/frame-clock/animation state machines silently freeze on their +# first frame in release builds. -dontoptimize is the only directive that +# disables processing of -assumenosideeffects/-assumevalues. See #5146. +-dontoptimize + # Dump the full merged R8 configuration (app rules + all library consumer rules) # for auditing. Inspect this file after a release build to see what libraries inject. -printconfiguration build/outputs/mapping/r8-merged-config.txt @@ -41,19 +54,16 @@ # ---- Compose Runtime & Animation -------------------------------------------- -# R8's optimization passes (bundled with AGP 9.x) can inline and dead-code- -# eliminate parts of the Compose frame-clock / recomposer / animation state -# machines, causing every animation to silently freeze on its first frame in -# release builds — indeterminate progress spinners, crossfade transitions, -# animateFloatAsState, AnimatedVisibility, etc. -# -# The frame clock lives in compose.runtime, the draw loop in compose.ui, -# and the animation drivers in compose.animation.core. Keep all three so -# R8 does not break the chain. +# 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 Multiplatform -------------------------------------------------- From 0f900fe7d79e084742ff2daeff7e3ae2b15c2197 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:12:53 -0500 Subject: [PATCH 02/38] chore(deps): update core/proto/src/main/proto digest to c9067da (#5151) 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 940ac382a..c9067dae4 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 940ac382a7d143040da5a880237f84c48ee31f2b +Subproject commit c9067dae4a540d75a0daf3fa4d9be89f5918124d From 8e5d99410c0692d33e94449516bc372facd2c8da Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:52:59 -0500 Subject: [PATCH 03/38] refactor(di): adopt @KoinApplication with startKoin() compiler plugin API (#5152) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .skills/navigation-and-di/SKILL.md | 20 +++++++++++--- .../org/meshtastic/app/MeshUtilApplication.kt | 8 +++--- .../org/meshtastic/app/di/AndroidKoinApp.kt | 26 +++++++++++++++++++ .../meshtastic/app/di/KoinVerificationTest.kt | 16 ++++++++++++ .../src/main/kotlin/KoinConventionPlugin.kt | 11 ++++---- 5 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md index e92e2cfa3..c9d7336a6 100644 --- a/.skills/navigation-and-di/SKILL.md +++ b/.skills/navigation-and-di/SKILL.md @@ -12,15 +12,26 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na 4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs. ### Anti-Patterns -- **A1 Module Compile Safety:** Do **not** enable A1 `compileSafety`. We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) because of our decoupled Clean Architecture design (interfaces in one module, implemented in another). +- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead. - **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values. ### Koin Startup Pattern (K2 Compiler Plugin) -The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The correct canonical startup for this path is: +The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin()` stub, which the plugin transforms at compile time via IR: ```kotlin -startKoin { modules(AppKoinModule().module()) } +// Bootstrap class — separate from @Module, references the root module graph +@KoinApplication(modules = [AppKoinModule::class]) +object AndroidKoinApp + +// In Application.onCreate() +startKoin { + androidContext(this@MeshUtilApplication) + workManagerFactory() +} ``` -Do **not** use `@KoinApplication` — that annotation is part of the **KSP annotations path** (`koin-ksp-compiler`) and generates a `startKoin()` extension via KSP. It is incompatible with the K2 plugin approach. The two paths are mutually exclusive; the project has deliberately chosen K2 for compile-time wiring without KSP overhead. +- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class. +- `startKoin()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`. +- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`). +- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag. ## Navigation 3 @@ -39,6 +50,7 @@ Do **not** use `@KoinApplication` — that annotation is part of the **KSP annot ## Reference Anchors - **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` +- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt` - **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` - **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` - **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index 34d4797cd..9228b6874 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -37,9 +37,8 @@ import kotlinx.coroutines.withTimeout import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.androidx.workmanager.koin.workManagerFactory -import org.koin.core.context.startKoin -import org.meshtastic.app.di.AppKoinModule -import org.meshtastic.app.di.module +import org.koin.plugin.module.dsl.startKoin +import org.meshtastic.app.di.AndroidKoinApp import org.meshtastic.core.common.ContextServices import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.repository.MeshPrefs @@ -64,10 +63,9 @@ open class MeshUtilApplication : super.onCreate() ContextServices.app = this - startKoin { + startKoin { androidContext(this@MeshUtilApplication) workManagerFactory() - modules(AppKoinModule().module()) } // Schedule periodic MeshLog cleanup diff --git a/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt new file mode 100644 index 000000000..04f0350c8 --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.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.app.di + +import org.koin.core.annotation.KoinApplication + +/** + * Root Koin bootstrap for Android. The K2 compiler plugin uses this to discover the full module graph when + * [org.koin.plugin.module.dsl.startKoin] is called with this type parameter. + */ +@KoinApplication(modules = [AppKoinModule::class]) +object AndroidKoinApp diff --git a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt index 7b140cca8..30e1b6be7 100644 --- a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt @@ -25,6 +25,7 @@ import androidx.work.WorkerParameters import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import kotlinx.coroutines.CoroutineDispatcher +import org.koin.plugin.module.dsl.koinApplication import org.koin.test.verify.definition import org.koin.test.verify.injectedParameters import org.koin.test.verify.verify @@ -60,4 +61,19 @@ class KoinVerificationTest { ), ) } + + @Test + fun verifyTypedBootstrapLoadsModuleGraph() { + // koinApplication() is a K2 compiler plugin stub. If the plugin fails to + // transform it, the stub throws NotImplementedError at runtime. This test + // validates that the production bootstrap path is correctly transformed by + // successfully creating and closing the generated Koin application. + val app = koinApplication() + try { + // No-op: reaching this point proves the typed bootstrap path did not + // throw and the generated application could be created. + } finally { + app.close() + } + } } diff --git a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt index 9b832ce16..b4f2acfbe 100644 --- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt @@ -29,11 +29,12 @@ class KoinConventionPlugin : Plugin { // Configure Koin K2 Compiler Plugin (0.4.0+) extensions.configure(KoinGradleExtension::class.java) { - // Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1 - // per-module safety checks strictly enforce that all dependencies must be explicitly - // provided or included locally. This breaks decoupled Clean Architecture designs. - // We disable compile safety globally to properly rely on Koin's A3 full-graph - // validation which perfectly handles inverted dependencies at the composition root. + // Meshtastic uses dependency inversion across KMP modules — interfaces in + // commonMain, implementations wired at the composition root. Koin's compileSafety + // flag enables A1 per-module checks that treat every module as self-contained, + // which breaks this pattern. There is no separate flag for A3 full-graph + // validation. Until Koin exposes granular safety levels we keep this disabled; + // runtime graph verification is handled by KoinVerificationTest instead. compileSafety.set(false) } From a580cd04675e203b77a0e70e694565effb1bafbe Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:09:21 -0500 Subject: [PATCH 04/38] chore(analytics): disable Datadog Compose action tracking (#5153) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/build.gradle.kts | 1 - .../org/meshtastic/app/analytics/GooglePlatformAnalytics.kt | 2 -- .../convention/src/main/kotlin/AnalyticsConventionPlugin.kt | 4 ++-- gradle/libs.versions.toml | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0d16a86b..d239d0530 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -279,7 +279,6 @@ dependencies { googleImplementation(libs.maps.compose) googleImplementation(libs.maps.compose.utils) googleImplementation(libs.maps.compose.widgets) - googleImplementation(libs.dd.sdk.android.compose) googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.rum) googleImplementation(libs.dd.sdk.android.session.replay) diff --git a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt index bf42494e5..0583dd78e 100644 --- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt +++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt @@ -26,7 +26,6 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Severity import com.datadog.android.Datadog import com.datadog.android.DatadogSite -import com.datadog.android.compose.enableComposeActionTracking import com.datadog.android.core.configuration.Configuration import com.datadog.android.log.Logger import com.datadog.android.log.Logs @@ -160,7 +159,6 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic .trackFrustrations(false) // Disable click-tracking based frustration detection .trackLongTasks() .trackNonFatalAnrs(true) - .enableComposeActionTracking() // Required: activates runtime consumption of Compose semantics tags .setSessionSampleRate(sampleRate) .build() Rum.enable(rumConfiguration) diff --git a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt index 046e3c4aa..16166a776 100644 --- a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt @@ -18,7 +18,7 @@ import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.datadog.gradle.plugin.DdExtension import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask -import com.datadog.gradle.plugin.InstrumentationMode + import com.datadog.gradle.plugin.SdkCheckLevel import org.gradle.api.Plugin import org.gradle.api.Project @@ -110,7 +110,7 @@ class AnalyticsConventionPlugin : Plugin { variants { register(variant.name) { site = "US5" - composeInstrumentation = InstrumentationMode.AUTO + } } checkProjectDependencies = SdkCheckLevel.NONE diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d3c73074..e9754e265 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -211,7 +211,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } -dd-sdk-android-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dd-sdk-android" } + dd-sdk-android-logs = { module = "com.datadoghq:dd-sdk-android-logs", version.ref = "dd-sdk-android" } dd-sdk-android-rum = { module = "com.datadoghq:dd-sdk-android-rum", version.ref = "dd-sdk-android" } dd-sdk-android-session-replay = { module = "com.datadoghq:dd-sdk-android-session-replay", version.ref = "dd-sdk-android" } From 50896d455b56125eed859d01fb31fde4faa2415f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:33:13 -0500 Subject: [PATCH 05/38] chore(deps): update dd.sdk.android to v3.9.0 (#5156) 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 e9754e265..c6e837984 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,7 +64,7 @@ aboutlibraries = "14.0.1" jserialcomm = "2.11.4" coil = "3.4.0" datadog-gradle = "1.25.0" -dd-sdk-android = "3.8.0" +dd-sdk-android = "3.9.0" detekt = "1.23.8" dokka = "2.2.0" devtools-ksp = "2.3.6" From 3a2f2fc56b6f418cea8cef90dfd8c036a6068d7c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:33:25 -0500 Subject: [PATCH 06/38] chore(deps): update kotlin to v2.3.21-rc2 (#5155) 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 c6e837984..1ecd560a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ koin = "4.2.1" koin-plugin = "1.0.0-RC1" # Kotlin -kotlin = "2.3.21-RC" +kotlin = "2.3.21-RC2" kotlinx-coroutines-android = "1.10.2" kotlinx-datetime = "0.7.1" kotlinx-serialization = "1.11.0" From 872c566ef1632c340e2fff3550b399c9a30acc90 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:25:12 -0500 Subject: [PATCH 07/38] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5157) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-de/strings.xml | 9 +++ .../values-zh-rTW/strings.xml | 79 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 4657dc925..141e717b2 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -253,10 +253,13 @@ Auf Standardeinstellungen zurücksetzen Anwenden Design + Kontrast Hell Dunkel System Design auswählen + Kontrast + Standard Medium Fast Hoch Standort zum Mesh angeben @@ -356,6 +359,8 @@ Akku Kanalauslastung Sendezeit + %1$s: %2$s%% + %1$s: %2$s V %1$s %1$s: %2$s Temperatur @@ -582,6 +587,9 @@ Ausgabedauer (GPIO) Nervige Verzögerung (Sekunden) Klingelton + Importierter Klingelton + Datei ist leer + Fehler beim Importieren: %1$s Wiedergabe I2S als Buzzer verwenden LoRa @@ -720,6 +728,7 @@ Regen (24 Std.) Gewicht Strahlung + 1-Wire Temperature Luftqualität im Innenbereich (IAQ) URL 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 030532869..72ab1373a 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -18,6 +18,7 @@ Meshtastic + Meshtastic %1$s 過濾器 清除節點過濾器 篩選條件 @@ -40,6 +41,7 @@ 內部傳輸 通過喜好 僅顯示已忽略的節點 + 排除 MQTT 無法識別 正在等待確認 發送佇列中 @@ -168,12 +170,20 @@ 正在連線 未連線 未選擇裝置 + 未知的裝置 + 找不到網路裝置 + 找不到 USB 裝置 + USB + 展示模式 已連接裝置,但該裝置正在休眠中 需要應用程式更新 您必須在應用商店(或 Github)更新此應用程式。它太舊無法與此無線電韌體通訊。請閱讀我們關於此主題的文件 無(停用) 服務通知 致謝 + 開放原始碼函式庫 + Meshtastic 採用以下開源函式庫建構而成。點擊任一函式庫以查看其授權條款。 + %1$d 函式庫 此頻道 URL 無效,無法使用 偵錯面板 解析封包: @@ -204,7 +214,14 @@ 符合全部條件 這將完全移除裝置上的所有日誌封包與資料庫記錄 - 這是一個完整的重設,且無法復原。 清除 + 搜尋表情符號…… + 更多符號 頻道 + %1$s: %2$s + 來自 %1$s 的訊息:%2$s + 標頭 + 這是一個一個一個可客製化的組合元件 + 還支援多行文字與多種樣式 訊息傳遞狀態 下方有新的訊息 私訊通知 @@ -225,10 +242,13 @@ 恢復預設設置 套用 主題 + 對比度 淺色 深色 系統預設 選擇主題 + 對比度等級 + 標準 中等 將手機位置提供給Mesh網路 @@ -316,6 +336,8 @@ 目前: 永久靜音 未靜音 + 已靜音 %1$d 天 %2$s 小時 + 已靜音 %1$s 小時 將「%1$s」的通知設為靜音? 取消「%1$s」的通知靜音? 替換 @@ -325,7 +347,10 @@ 電池 頻道利用率 空中時間使用率 + %1$s:%2$s%% + %1$s:%2$s%V %1$s + %1$s:%2$s 溫度 濕度 土壤溫度 @@ -379,12 +404,21 @@ 持續時間:%1$s 秒 追蹤至目的地的路由:\n\n 追蹤回到本機的路由:\n\n + 去程跳數 + 回程跳數 + 來回跳數 + 無回應 + 可用系統記憶體(位元組) 1小時 二十四小時 一週 二週 1個月 最大值 + 最小 + 平均 + 展開圖表 + 收起圖表 未知年齡 複製 警鈴字符! @@ -398,11 +432,17 @@ 頻道1 頻道2 頻道3 + 頻道 4 + 頻道 5 + 頻道 6 + 頻道 7 + 頻道 8 當前 電壓 你確定嗎? 設備角色檔案和關於選擇正確的設備角色的博客文章 。]]> 我知道我在做什麼。 + 節點 %1$s 電量過低 (%2$d%) 低電量通知 低電量:%1$s 低電量通知(收藏節點) @@ -528,6 +568,9 @@ 輸出持續時間(毫秒) 通知逾時時間(秒) 鈴聲 + 已匯入鈴聲 + 檔案為空 + 匯入錯誤:%1$s 播放 使用 I2S 控制蜂鳴器 LoRa @@ -622,6 +665,8 @@ 啟用序列埠 啟用 Echo 序列埠鮑率 + RX + TX 逾時 序列埠模式 覆蓋控制台序列埠 @@ -656,8 +701,15 @@ 距離 照度 風速 + 風速 + 陣風 + 風停 + 風向 + 降雨(1h) + 降雨(24h) 重量 輻射 + 1-Wire 溫度 室內空氣品質 (IAQ) 網址 @@ -674,6 +726,7 @@ 時間戳記 航向 速度 + %1$d Km/h 衛星數 海拔 頻率 @@ -739,6 +792,8 @@ 顯示路徑 顯示定位精準度 客户端通知 + 偵測到重複的公鑰 + 偵測到加密金鑰強度不足 偵測到金鑰已洩漏,點選確定後重新產生金鑰。 重新產生私鑰 您確定要重新產生密鑰嗎?\n\n連線過的其他節點需要刪除並重新交換金鑰後才能恢復加密通訊連線。 @@ -791,6 +846,12 @@ 請輸入訊息 PAX 人流計量 PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s 無可用的 PAX 人流計量資料。 藍牙裝置 連接裝置 @@ -912,6 +973,7 @@ 穩定版 Alpha 測試版 注意:更新期間將會暫時中斷您的裝置連線。 + 正在下載韌體... %1$d% 錯誤: %1$s 重試 更新成功! @@ -954,6 +1016,7 @@ 版本說明 未知錯誤 缺少節點使用者資訊。 + 電量過低 (%1$d%%),請在更新前為您的裝置充電。 無法取得韌體檔案。 USB 更新失敗 韌體雜湊值遭拒。裝置可能需要雜湊值配置或開機載入程式更新。 @@ -1027,8 +1090,10 @@ 設定 無線管理你的裝置設定與頻道。 地圖樣式選擇 + 電量:%1$d% 線上 %1$d / 總計 %2$d 上線時間: %1$s + 頻道使用率: %1$s% | 空中傳輸佔用率: %2$s% 流量: 傳送 %1$d / 接收 %2$d (丟棄: %3$d) 中繼: %1$d (取消: %2$d) 診斷: %1$s @@ -1047,6 +1112,8 @@ 新增本機 MBTiles 檔案 TAK (ATAK) TAK 設定 + 啓用本地 TAK 伺服器 + 在 8089 埠啟動一個用於 ATAK 連線的 TCP 伺服器 隊伍顏色 隊員角色 未指定 @@ -1090,6 +1157,18 @@ 僅本地定位資訊(中繼) 保留路由跳數 注意 + 裝置儲存空間與使用者介面(唯讀) + 未發現任何檔案。 連線 完成 + mPWRD-OS 的 Wi-Fi 設定 + 透過藍牙為您的 mPWRD-OS 裝置設定 Wi-Fi 憑證。 + 進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS + 正在搜尋裝置… + 找到裝置 + 正在搜尋… + 找不到網路 + %1$d% + 可用的網路 + 網路名稱(SSID) From 17e69c6d4c717cf75fa0fd61a9cba79e3465ac52 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:02:59 -0500 Subject: [PATCH 08/38] chore: review-cleanup fleet (audit + fix + hardening) (#5158) --- .github/lsp.json | 12 ++ .github/workflows/models_pr_triage.yml | 14 +- .skills/new-branch/SKILL.md | 79 +++++++ AGENTS.md | 28 +++ CLAUDE.md | 2 +- app/proguard-rules.pro | 37 +--- .../kotlin/org/meshtastic/app/map/MapView.kt | 12 +- build-logic/convention/build.gradle.kts | 1 - .../AndroidApplicationConventionPlugin.kt | 16 +- .../KmpLibraryComposeConventionPlugin.kt | 10 +- .../main/kotlin/KmpLibraryConventionPlugin.kt | 4 - .../meshtastic/buildlogic/KotlinAndroid.kt | 4 +- config/proguard/shared-rules.pro | 179 ++++++++++++++++ .../meshtastic/core/common/util/Exceptions.kt | 15 ++ .../data/manager/MeshActionHandlerImpl.kt | 4 +- .../core/data/manager/PacketHandlerImpl.kt | 31 ++- .../data/repository/PacketRepositoryImpl.kt | 6 +- .../data/manager/MeshActionHandlerImplTest.kt | 9 +- .../core/database/dao/CommonPacketDaoTest.kt | 36 ++++ core/model/build.gradle.kts | 2 + core/navigation/build.gradle.kts | 2 + .../repository/TrustAllX509TrustManager.kt | 31 --- .../core/network/radio/BleRadioTransport.kt | 16 +- .../core/network/radio/MockRadioTransport.kt | 2 +- .../core/network/radio/NopRadioTransport.kt | 2 +- .../core/network/radio/StreamTransport.kt | 2 +- .../core/network/radio/TcpRadioTransport.kt | 2 +- .../core/network/SerialTransport.kt | 2 +- .../org/meshtastic/core/prefs/FlowCache.kt | 38 ++-- .../core/prefs/map/MapConsentPrefsImpl.kt | 2 +- .../core/prefs/mesh/MeshPrefsImpl.kt | 15 +- .../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 2 +- .../core/repository/AppPreferences.kt | 4 - .../core/repository/RadioInterfaceService.kt | 19 +- .../core/repository/RadioTransport.kt | 13 +- .../core/repository/RadioTransportTest.kt | 5 +- .../composeResources/values/strings.xml | 4 + .../core/service/MeshServiceOrchestrator.kt | 65 +++--- .../service/SharedRadioInterfaceService.kt | 36 +++- .../service/MeshServiceOrchestratorTest.kt | 86 +++++++- .../core/takserver/TAKServerManager.kt | 24 ++- core/testing/build.gradle.kts | 4 +- .../core/testing/FakeAppPreferences.kt | 9 - .../core/testing/FakeRadioInterfaceService.kt | 20 +- .../core/testing/FakeRadioTransport.kt | 2 +- desktop/build.gradle.kts | 5 +- desktop/proguard-rules.pro | 194 +++--------------- .../kotlin/org/meshtastic/desktop/Main.kt | 11 +- .../DesktopMeshServiceNotifications.kt | 3 +- .../org/meshtastic/desktop/stub/NoopStubs.kt | 4 + .../CommonGetDiscoveredDevicesUseCase.kt | 7 +- .../ui/components/DeviceListItem.kt | 9 +- .../feature/firmware/ota/BleOtaTransport.kt | 3 +- .../firmware/ota/Esp32OtaUpdateHandler.kt | 6 +- .../feature/firmware/ota/WifiOtaTransport.kt | 2 +- .../firmware/ota/dfu/SecureDfuHandler.kt | 4 +- .../firmware/ota/dfu/SecureDfuTransport.kt | 7 +- .../DefaultFirmwareUpdateManagerTest.kt | 10 + feature/settings/build.gradle.kts | 1 + .../feature/settings/tak/TakPermissionUtil.kt | 2 +- .../navigation/AboutLibrariesLoader.kt | 0 .../navigation/AboutLibrariesLoader.kt | 22 -- .../feature/widget/LocalStatsWidgetState.kt | 17 +- feature/wifi-provision/build.gradle.kts | 1 + .../wifiprovision/WifiProvisionViewModel.kt | 4 +- .../wifiprovision/domain/NymeaWifiService.kt | 3 +- .../WifiProvisionViewModelTest.kt | 11 +- gradle/libs.versions.toml | 9 - 68 files changed, 784 insertions(+), 459 deletions(-) create mode 100644 .github/lsp.json create mode 100644 .skills/new-branch/SKILL.md create mode 100644 config/proguard/shared-rules.pro delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt rename feature/settings/src/{androidMain => jvmAndroidMain}/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt (100%) delete mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt diff --git a/.github/lsp.json b/.github/lsp.json new file mode 100644 index 000000000..983ecf785 --- /dev/null +++ b/.github/lsp.json @@ -0,0 +1,12 @@ +{ + "lspServers": { + "kotlin": { + "command": "kotlin-language-server", + "args": [], + "fileExtensions": { + ".kt": "kotlin", + ".kts": "kotlin" + } + } + } +} diff --git a/.github/workflows/models_pr_triage.yml b/.github/workflows/models_pr_triage.yml index 2cfe6b15e..c2a1aaf25 100644 --- a/.github/workflows/models_pr_triage.yml +++ b/.github/workflows/models_pr_triage.yml @@ -44,13 +44,16 @@ jobs: uses: actions/ai-inference@v2 id: quality continue-on-error: true + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} with: max-tokens: 20 prompt: | Is this GitHub pull request spam, AI-generated slop, or low quality? - Title: ${{ github.event.pull_request.title }} - Body: ${{ github.event.pull_request.body }} + Title: ${{ env.PR_TITLE }} + Body: ${{ env.PR_BODY }} Respond with exactly one of: spam, ai-generated, needs-review, ok system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop. @@ -94,6 +97,9 @@ jobs: uses: actions/ai-inference@v2 id: classify continue-on-error: true + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} with: max-tokens: 30 prompt: | @@ -105,8 +111,8 @@ jobs: Use enhancement if it adds a new feature, improves performance, or adds new functionality. Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture. - Title: ${{ github.event.pull_request.title }} - Body: ${{ github.event.pull_request.body }} + Title: ${{ env.PR_TITLE }} + Body: ${{ env.PR_BODY }} system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label. model: openai/gpt-4o-mini diff --git a/.skills/new-branch/SKILL.md b/.skills/new-branch/SKILL.md new file mode 100644 index 000000000..d63f3f4c2 --- /dev/null +++ b/.skills/new-branch/SKILL.md @@ -0,0 +1,79 @@ +# Skill: New Branch Bootstrap + +## Description +Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill +whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh +branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work. + +This replaces the ad-hoc prose that used to be retyped at the start of every session. + +## When to Use +- Starting any new feature, fix, chore, or refactor. +- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)). +- Reproducing a CI failure from a clean baseline. + +## Preconditions (verify before branching) +1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding. +2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at + `meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream. +3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md + workspace bootstrap rules. +4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties` + (required for `google` flavor builds). + +## Standard Recipe + +```bash +# 1. Fetch latest upstream +git fetch upstream --prune --tags + +# 2. Create the branch from upstream/main (never from a local stale main) +git switch -c upstream/main + +# 3. Ensure submodules track the new base +git submodule update --init --recursive + +# 4. Sanity check +git --no-pager log -1 --oneline +``` + +## Branch Naming +Use conventional-commit style prefixes that match the PR title convention in AGENTS.md +``: + +| Prefix | Use for | +| :--- | :--- | +| `feat/` | New user-visible behavior | +| `fix/` | Bug fixes | +| `refactor/` | Code structure changes, no behavior change | +| `chore/` | Tooling, deps, CI, cleanup | +| `docs/` | Documentation only | + +Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`. + +## Rebase Variant +When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*: + +```bash +git fetch upstream --prune +gh pr checkout # checks out the PR head locally +git rebase upstream/main +git submodule update --init --recursive +# Resolve conflicts, then: +git push --force-with-lease +``` + +Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes. + +## Post-Branch Checklist +- [ ] Branch name follows conventional prefix. +- [ ] Submodules up to date. +- [ ] `local.properties` exists. +- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap). +- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing. + +## Tip: Prefer `/delegate` for Long Audits +If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since +v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider +suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR +end-to-end while the user keeps working locally. See AGENTS.md ``. diff --git a/AGENTS.md b/AGENTS.md index 07d9b0050..c1bafdd96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes - `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties. - `.skills/implement-feature/` - Step-by-step feature workflow. - `.skills/code-review/` - PR validation checklist. + - `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs. - **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch. @@ -73,6 +74,33 @@ Do NOT duplicate content into agent-specific files. When you modify architecture - **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change. + +These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this +section. + +- **Delegate long autonomous work.** For sweeping audits, multi-hour investigations, or "fleet" + prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*, + *"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub + cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive + session on work that can run unattended. +- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"* + on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep + research across GitHub and the web with better source grounding than an ad-hoc prompt. +- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation + plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to + plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent + from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to + `.agent_plans/` (git-ignored) for multi-module refactors. +- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle + quality passes, offer `/share` to export the findings to a gist or markdown file. These + reports are valuable artifacts — don't let them die in session history. +- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts + file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration. + Avoid re-issuing the same prompt verbatim. +- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main" + or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe. + + - **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation. - **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style. diff --git a/CLAUDE.md b/CLAUDE.md index 39958ecd0..eb5cd5e5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,4 +6,4 @@ - **Think First:** Always outline your step-by-step reasoning inside `` tags before writing code or shell commands. Claude models perform significantly better on complex KMP tasks when they "think out loud" first. - **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task. -- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). +- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `` section. diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d443e173a..14df5580d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -3,13 +3,17 @@ # ============================================================================ # Open-source project: obfuscation and optimization are disabled. We rely on # tree-shaking (unused code removal) for APK size reduction. +# +# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, +# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, +# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in +# config/proguard/shared-rules.pro and are wired in by the +# AndroidApplicationConventionPlugin. This file holds only Android-specific +# rules and R8-only directives. # ============================================================================ # ---- General ---------------------------------------------------------------- -# Preserve line numbers for meaningful crash traces --keepattributes SourceFile,LineNumberTable - # Open-source — no need to obfuscate -dontobfuscate @@ -30,28 +34,12 @@ # for auditing. Inspect this file after a release build to see what libraries inject. -printconfiguration build/outputs/mapping/r8-merged-config.txt -# ---- Networking (transitive references from Ktor) --------------------------- +# ---- Networking (transitive references from Ktor on Android) ---------------- -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** -# ---- Wire Protobuf ---------------------------------------------------------- - -# Wire-generated proto message classes (accessed via ADAPTER companion reflection) --keep class org.meshtastic.proto.** { *; } - -# ---- Room KMP (room3) ------------------------------------------------------ - -# Preserve generated database constructors (Room uses reflection to instantiate) --keep class * extends androidx.room3.RoomDatabase { (); } - -# ---- Koin DI ---------------------------------------------------------------- - -# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException -# replacing Koin's InstanceCreationException in stack traces, making crashes undiagnosable). --keep class org.koin.core.error.** { *; } - # ---- Compose Runtime & Animation -------------------------------------------- # Defence-in-depth: prevent R8 tree-shaking of Compose infrastructure classes @@ -64,12 +52,3 @@ -keep class androidx.compose.animation.** { *; } -keep class androidx.compose.foundation.** { *; } -keep class androidx.compose.material3.** { *; } - -# ---- Compose Multiplatform -------------------------------------------------- - -# Keep resource library internals and generated Res accessor classes so R8 does -# not tree-shake the resource loading infrastructure. Without these rules the -# fdroid flavor crashes at startup with a misleading URLDecodeException due to -# R8 exception-class merging. --keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.** { *; } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 657f7ab74..b4d0e1bbd 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -861,15 +861,9 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { onDismiss = onDismiss, negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, ) { - Text( - modifier = Modifier.padding(16.dp), - text = - stringResource( - Res.string.map_cache_info, - cacheCapacity / (1024.0 * 1024.0), - currentCacheUsage / (1024.0 * 1024.0), - ), - ) + val capacityMb = (cacheCapacity / (1024 * 1024)).toLong() + val usageMb = (currentCacheUsage / (1024 * 1024)).toLong() + Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb)) } } diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index faaeb9f68..71823c763 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -54,7 +54,6 @@ dependencies { compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.androidx.room.gradlePlugin) - compileOnly(libs.secrets.gradlePlugin) compileOnly(libs.spotless.gradlePlugin) compileOnly(libs.test.retry.gradlePlugin) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index cc53f27ec..38cc021a7 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * 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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - import com.android.build.api.dsl.ApplicationExtension import org.gradle.api.Plugin import org.gradle.api.Project @@ -26,7 +25,6 @@ import org.meshtastic.buildlogic.configureTestOptions class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - apply(plugin = "com.android.application") apply(plugin = "org.gradle.test-retry") apply(plugin = "meshtastic.android.lint") @@ -38,11 +36,8 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - - defaultConfig { - vectorDrawables.useSupportLibrary = true - } + defaultConfig { vectorDrawables.useSupportLibrary = true } buildTypes { getByName("release") { @@ -50,7 +45,8 @@ class AndroidApplicationConventionPlugin : Plugin { isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + rootProject.file("config/proguard/shared-rules.pro"), + "proguard-rules.pro", ) } getByName("debug") { @@ -62,9 +58,7 @@ class AndroidApplicationConventionPlugin : Plugin { } } - buildFeatures { - buildConfig = true - } + buildFeatures { buildConfig = true } } configureTestOptions() } diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt index 2a9504221..67b2c8fd0 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt @@ -32,10 +32,12 @@ class KmpLibraryComposeConventionPlugin : Plugin { apply(plugin = libs.plugin("compose-multiplatform").get().pluginId) extensions.configure { - sourceSets.getByName("commonMain").dependencies { - implementation(libs.library("compose-multiplatform-runtime")) - // API because consuming modules will usually need the resource types - api(libs.library("compose-multiplatform-resources")) + sourceSets.matching { it.name == "commonMain" }.configureEach { + dependencies { + implementation(libs.library("compose-multiplatform-runtime")) + // API because consuming modules will usually need the resource types + api(libs.library("compose-multiplatform-resources")) + } } } configureComposeCompiler() diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt index a1a111a64..540834ef5 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -14,11 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import dev.mokkery.gradle.MokkeryGradleExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply -import org.gradle.kotlin.dsl.configure import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback import org.meshtastic.buildlogic.configureKmpTestDependencies import org.meshtastic.buildlogic.configureKotlinMultiplatform @@ -39,8 +37,6 @@ class KmpLibraryConventionPlugin : Plugin { apply(plugin = "org.gradle.test-retry") apply(plugin = libs.plugin("mokkery").get().pluginId) - extensions.configure { stubs.allowConcreteClassInstantiation.set(true) } - configureKotlinMultiplatform() configureKmpTestDependencies() configureTestOptions() diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index c7afeaf39..088ca0d25 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -77,7 +77,9 @@ internal fun Project.configureKotlinMultiplatform() { // upgrades to the CMP-bundled version, triggering a "Skiko dependencies' // versions are incompatible" warning from CMP's compatibility checker. // Force the version to match CMP so the checker sees a consistent graph. - val skikoVersion = libs.version("skiko") + // Pinned here rather than in the version catalog because this plugin is the + // only consumer — bump together with the compose-multiplatform version. + val skikoVersion = "0.144.5" configurations.configureEach { resolutionStrategy.eachDependency { if (requested.group == "org.jetbrains.skiko") { diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro new file mode 100644 index 000000000..902636dbf --- /dev/null +++ b/config/proguard/shared-rules.pro @@ -0,0 +1,179 @@ +# ============================================================================ +# Meshtastic — Shared ProGuard / R8 rules +# ============================================================================ +# Cross-platform keep and dontwarn rules applied to BOTH the Android app +# release build (R8) and the Desktop distribution (ProGuard). Host-specific +# rules live in the per-module proguard-rules.pro file. +# +# Rule of thumb: anything describing a library shared between Android and +# Desktop (Koin, kotlinx-serialization, Wire, Room KMP, Ktor, Coil 3, Kable, +# Kermit, Okio, DataStore, Paging, Lifecycle / Navigation 3, AboutLibraries, +# Markdown renderer, QRCode, Compose Multiplatform resources, core modules) +# belongs here. Anything platform-specific (AWT/Skiko/JNA, AIDL, Android +# framework, JDK-version quirks, flavor specifics) stays in the host file. +# ============================================================================ + +# ---- Attributes ------------------------------------------------------------- + +# Preserve line numbers for meaningful stack traces, plus metadata needed for +# reflective serializer/DI/Room lookups. +-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations + +# ---- Kotlin / Coroutines ---------------------------------------------------- + +-keep class kotlin.Metadata { *; } +-keep class kotlin.reflect.** { *; } +-keep class kotlin.coroutines.Continuation { *; } +-keep class kotlinx.coroutines.** { *; } +-dontwarn kotlinx.coroutines.** + +# ---- Koin DI (reflection-based injection) ----------------------------------- + +# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException +# replacing Koin's InstanceCreationException in stack traces, making crashes +# undiagnosable). Broadened to all of koin core to cover the KSP-generated graph. +-keep class org.koin.** { *; } +-dontwarn org.koin.** + +# Keep Koin-annotated modules/components so Koin Annotations (KSP) output +# survives tree-shaking. +-keep @org.koin.core.annotation.Module class * { *; } +-keep @org.koin.core.annotation.ComponentScan class * { *; } +-keep @org.koin.core.annotation.Single class * { *; } +-keep @org.koin.core.annotation.Factory class * { *; } + +# Generated Koin module extensions (Koin Annotations plugin output) +-keep class org.meshtastic.**.di.** { *; } + +# ---- kotlinx-serialization -------------------------------------------------- + +-keep class kotlinx.serialization.** { *; } +-dontwarn kotlinx.serialization.** + +# Keep @Serializable classes and their generated $serializer companions +-keepclassmembers @kotlinx.serialization.Serializable class ** { + static ** Companion; + kotlinx.serialization.KSerializer serializer(...); +} +-keep class **.$serializer { *; } +-keepclassmembers class **.$serializer { *; } +-keepclasseswithmembers class ** { + kotlinx.serialization.KSerializer serializer(...); +} + +# ---- Wire Protobuf ---------------------------------------------------------- + +# Wire generates ADAPTER companion objects accessed via reflection +-keep class com.squareup.wire.** { *; } +-dontwarn com.squareup.wire.** + +# Generated proto message classes (both meshtastic protos and internal package) +-keep class org.meshtastic.proto.** { *; } +-keep class meshtastic.** { *; } + +# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs +# when compiling for non-Android JVM targets; harmless on Android). +-dontwarn android.os.Parcel** +-dontwarn android.os.Parcelable** + +# ---- Room KMP (room3) ------------------------------------------------------- + +# Preserve generated database constructors (Room uses reflection to instantiate) +-keep class * extends androidx.room3.RoomDatabase { (); } +-keep class * implements androidx.room3.RoomDatabaseConstructor { *; } + +# Keep the expect/actual MeshtasticDatabaseConstructor + database surface +-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } +-keep class org.meshtastic.core.database.MeshtasticDatabase { *; } + +# Room DAOs — Room generates implementations at compile time; keep interfaces +-keep class org.meshtastic.core.database.dao.** { *; } + +# Room Entities — accessed via reflection for column mapping +-keep class org.meshtastic.core.database.entity.** { *; } + +# Room TypeConverters — invoked reflectively +-keep class org.meshtastic.core.database.Converters { *; } + +# Room generated _Impl classes +-keep class **_Impl { *; } + +# ---- SQLite bundled -------------------------------------------------------- + +-keep class androidx.sqlite.** { *; } +-dontwarn androidx.sqlite.** + +# ---- Ktor (ServiceLoader + plugin discovery) -------------------------------- + +-keep class io.ktor.** { *; } +-dontwarn io.ktor.** + +# Keep ServiceLoader metadata files +-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } + +# ---- Coil 3 (image loading) ------------------------------------------------- + +-keep class coil3.** { *; } +-dontwarn coil3.** + +# ---- Kable BLE -------------------------------------------------------------- + +-keep class com.juul.kable.** { *; } +-dontwarn com.juul.kable.** + +# ---- Compose Multiplatform resources ---------------------------------------- + +# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.). +# Without these the fdroid flavor has crashed at startup with a misleading +# URLDecodeException due to R8 exception-class merging. +-keep class org.jetbrains.compose.resources.** { *; } +-keep class org.meshtastic.core.resources.** { *; } + +# ---- AboutLibraries --------------------------------------------------------- + +-keep class com.mikepenz.aboutlibraries.** { *; } +-dontwarn com.mikepenz.aboutlibraries.** + +# ---- Multiplatform Markdown Renderer ---------------------------------------- + +-keep class com.mikepenz.markdown.** { *; } +-dontwarn com.mikepenz.markdown.** + +# ---- QR Code Kotlin --------------------------------------------------------- + +-keep class io.github.g0dkar.qrcode.** { *; } +-dontwarn io.github.g0dkar.qrcode.** +-keep class qrcode.** { *; } +-dontwarn qrcode.** + +# ---- Kermit logging --------------------------------------------------------- + +-keep class co.touchlab.kermit.** { *; } +-dontwarn co.touchlab.kermit.** + +# ---- Okio ------------------------------------------------------------------- + +-keep class okio.** { *; } +-dontwarn okio.** + +# ---- DataStore -------------------------------------------------------------- + +-keep class androidx.datastore.** { *; } +-dontwarn androidx.datastore.** + +# ---- Paging ----------------------------------------------------------------- + +-keep class androidx.paging.** { *; } +-dontwarn androidx.paging.** + +# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) ----------------- + +-keep class androidx.lifecycle.** { *; } +-keep class androidx.navigation3.** { *; } +-dontwarn androidx.lifecycle.** +-dontwarn androidx.navigation3.** + +# ---- Meshtastic shared model ------------------------------------------------ + +# Core model classes (used in serialization, Room, and Koin injection) +-keep class org.meshtastic.core.model.** { *; } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt index c5d3c2091..92137375c 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt @@ -95,3 +95,18 @@ inline fun T.safeCatching(block: T.() -> R): Result = try { } catch (e: Exception) { Result.failure(e) } + +/** + * Like [safeCatching] but also catches JVM [Error]s (e.g. [ExceptionInInitializerError] raised by compose-resources' + * lazy skiko initialization on the desktop JVM test classpath). Still re-throws [CancellationException] so structured + * concurrency is preserved. Use when the block invokes code whose failure modes include static-initializer errors and + * the caller only needs a best-effort fallback. + */ +@Suppress("TooGenericExceptionCaught") +inline fun safeCatchingAll(block: () -> T): Result = try { + Result.success(block()) +} catch (e: CancellationException) { + throw e +} catch (t: Throwable) { + Result.failure(t) +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index 975b2f5e8..ab4f3a551 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -45,6 +45,7 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -63,6 +64,7 @@ class MeshActionHandlerImpl( private val dataHandler: Lazy, private val analytics: PlatformAnalytics, private val meshPrefs: MeshPrefs, + private val uiPrefs: UiPrefs, private val databaseManager: DatabaseManager, private val notificationManager: NotificationManager, private val messageProcessor: Lazy, @@ -207,7 +209,7 @@ class MeshActionHandlerImpl( override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) { if (destNum != myNodeNum) { - val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value + val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value val currentPosition = when { provideLocation && position.isValid() -> position diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt index 7c634ee8b..e2e9a8432 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -22,7 +22,9 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -73,6 +75,11 @@ class PacketHandlerImpl( private val queueMutex = Mutex() private val queuedPackets = mutableListOf() + // Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket) + // calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and + // a single consumer coroutine enqueues packets under queueMutex in arrival order. + private val outboundChannel = Channel(Channel.UNLIMITED) + // Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked() // and the queue processor's finally block to prevent restarting a stopped queue. private var queueStopped = false @@ -80,6 +87,20 @@ class PacketHandlerImpl( private val responseMutex = Mutex() private val queueResponse = mutableMapOf>() + init { + // Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket) + // entry point, preserving FIFO across rapid concurrent callers. + scope.launch { + outboundChannel.consumeAsFlow().collect { packet -> + queueMutex.withLock { + queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. + queuedPackets.add(packet) + startPacketQueueLocked() + } + } + } + } + override fun sendToRadio(p: ToRadio) { Logger.d { "Sending to radio ${p.toPIIString()}" } val b = p.encode() @@ -104,13 +125,9 @@ class PacketHandlerImpl( } override fun sendToRadio(packet: MeshPacket) { - scope.launch { - queueMutex.withLock { - queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. - queuedPackets.add(packet) - startPacketQueueLocked() - } - } + // Non-suspend entry point — order-preserving via unbounded channel drained by + // a single consumer coroutine. trySend on UNLIMITED never fails for capacity. + outboundChannel.trySend(packet) } @Suppress("TooGenericExceptionCaught", "SwallowedException") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index a5664b1a0..149c62d2b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.dao.NodeInfoDao import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.database.entity.toReaction import org.meshtastic.core.di.CoroutineDispatchers @@ -242,7 +243,10 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val emptyMap() } else { withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().getPacketsByPacketIds(ids).associateBy { it.packet.packetId } + val dao = dbManager.currentDb.value.packetDao() + ids.chunked(NodeInfoDao.MAX_BIND_PARAMS) + .flatMap { dao.getPacketsByPacketIds(it) } + .associateBy { it.packet.packetId } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt index c53c2577e..5b29e9f26 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt @@ -47,6 +47,7 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -68,6 +69,7 @@ class MeshActionHandlerImplTest { private val dataHandler = mock(MockMode.autofill) private val analytics = mock(MockMode.autofill) private val meshPrefs = mock(MockMode.autofill) + private val uiPrefs = mock(MockMode.autofill) private val databaseManager = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) private val messageProcessor = mock(MockMode.autofill) @@ -100,6 +102,7 @@ class MeshActionHandlerImplTest { dataHandler = lazy { dataHandler }, analytics = analytics, meshPrefs = meshPrefs, + uiPrefs = uiPrefs, databaseManager = databaseManager, notificationManager = notificationManager, messageProcessor = lazy { messageProcessor }, @@ -356,7 +359,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() { handler = createHandler(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) val validPosition = Position(37.7749, -122.4194, 10) handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) @@ -367,7 +370,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() { handler = createHandler(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) every { nodeManager.nodeDBbyNodeNum } returns emptyMap() val invalidPosition = Position(0.0, 0.0, 0) @@ -380,7 +383,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_doNotProvide_sendsZeroPosition() { handler = createHandler(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) val validPosition = Position(37.7749, -122.4194, 10) handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt index 6da9df5b7..71a7fef1c 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt @@ -271,6 +271,42 @@ abstract class CommonPacketDaoTest { assertFalse(excludingFiltered.any { it.packet.filtered }) } + @Test + fun testGetPacketsByPacketIdsChunked() = runTest { + // Regression test for SQLITE_MAX_VARIABLE_NUMBER (999) limit. Inserting >999 packets and + // looking them up by id must not throw; callers are expected to chunk, and each chunk + // must return the correct rows. + val totalPackets = 2000 + val chunkSize = NodeInfoDao.MAX_BIND_PARAMS + val contactKey = "chunk-test" + val baseTime = nowMillis + val packetIds = (1..totalPackets).toList() + + packetIds.forEach { id -> + packetDao.insert( + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = PortNum.TEXT_MESSAGE_APP.value, + contact_key = contactKey, + received_time = baseTime + id, + read = false, + data = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = "Chunk $id".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ), + packetId = id, + ), + ) + } + + val fetched = packetIds.chunked(chunkSize).flatMap { packetDao.getPacketsByPacketIds(it) } + assertEquals(totalPackets, fetched.size) + assertEquals(packetIds.toSet(), fetched.map { it.packet.packetId }.toSet()) + } + companion object { private const val SAMPLE_SIZE = 10 } diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 4e01fc223..92374706a 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -58,6 +58,8 @@ kotlin { implementation(libs.androidx.test.runner) } } + + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 99a0802ae..858229b69 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -32,5 +32,7 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) implementation(libs.kermit) } + + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt deleted file mode 100644 index 720d2a522..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.repository - -import android.annotation.SuppressLint -import java.security.cert.X509Certificate -import javax.net.ssl.X509TrustManager - -@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") -@Suppress("EmptyFunctionBlock") -class TrustAllX509TrustManager : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - - override fun checkServerTrusted(chain: Array?, authType: String?) {} - - override fun getAcceptedIssuers(): Array = arrayOf() -} 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 cfc84c668..77114ff55 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 @@ -22,9 +22,8 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope @@ -37,6 +36,7 @@ import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.meshtastic.core.ble.BleConnection import org.meshtastic.core.ble.BleConnectionFactory @@ -396,14 +396,14 @@ class BleRadioTransport( } /** Closes the connection to the device. */ - override fun close() { + override suspend fun close() { Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" } connectionScope.cancel("close() called") - // GATT cleanup must outlive scope cancellation — GlobalScope is intentional. - // SharedRadioInterfaceService cancels the scope immediately after close(), so a - // coroutine launched there may never run, leaking BluetoothGatt (causes GATT 133). - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch { + // GATT cleanup must run under NonCancellable so a cancelled caller cannot skip it, + // which would leak BluetoothGatt and trigger status 133 on the next reconnect. + // Using withContext (not runBlocking) keeps the caller's thread free — this is + // critical when close() is invoked from the main thread during process shutdown. + withContext(NonCancellable) { try { withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() } } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt index b14c1bfe4..f8edeaa73 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt @@ -144,7 +144,7 @@ class MockRadioTransport( } } - override fun close() { + override suspend fun close() { Logger.i { "Closing the mock transport" } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt index db807081a..c8143b1c7 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt @@ -30,7 +30,7 @@ class NopRadioTransport(val address: String) : RadioTransport { // No-op } - override fun close() { + override suspend fun close() { // No-op } } 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 ff2e5e33e..ac912346a 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 @@ -35,7 +35,7 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p private val codec = StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport") - override fun close() { + override suspend fun close() { Logger.d { "Closing stream for good" } onDeviceDisconnect(true) } 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 7b1106dc4..354c4cd30 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 @@ -74,7 +74,7 @@ open class TcpRadioTransport( transport.start(address) } - override fun close() { + override suspend fun close() { Logger.d { "[$address] Closing TCP transport" } closing = true transport.stop() 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 d43063d52..a3f34d67e 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 @@ -154,7 +154,7 @@ private constructor( serialPort = null } - override fun close() { + override suspend fun close() { Logger.d { "[$portName] Closing serial transport" } readJob?.cancel() readJob = null diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt index 5395ce723..d6c85d266 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt @@ -19,19 +19,31 @@ package org.meshtastic.core.prefs import kotlinx.atomicfu.AtomicRef import kotlinx.collections.immutable.PersistentMap -internal inline fun cachedFlow(cache: AtomicRef>, key: K, build: () -> V): V { - var resolved = cache.value[key] - if (resolved == null) { - val newValue = build() - while (resolved == null) { - val current = cache.value - val currentValue = current[key] - if (currentValue != null) { - resolved = currentValue - } else if (cache.compareAndSet(current, current.put(key, newValue))) { - resolved = newValue - } +/** + * Look up [key] in [cache]; if absent, construct a value via [build] and insert it atomically. + * + * [build] is wrapped in a [Lazy] before being published to [cache], so concurrent first-access of the same key never + * invokes [build] more than once — only the winner of the CAS has its [Lazy] evaluated, and all readers share that same + * result. This matters when [build] eagerly launches a coroutine (e.g. `Flow.stateIn(scope, Eagerly, …)`): the naive + * approach would leak the losing coroutine into a never-cancelled scope. + */ +@Suppress("ReturnCount") +internal inline fun cachedFlow( + cache: AtomicRef>>, + key: K, + crossinline build: () -> V, +): V { + cache.value[key]?.let { + return it.value + } + val newLazy = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { build() } + while (true) { + val current = cache.value + current[key]?.let { + return it.value + } + if (cache.compareAndSet(current, current.put(key, newLazy))) { + return newLazy.value } } - return checkNotNull(resolved) } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt index 763c81120..c43d4b2bb 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt @@ -42,7 +42,7 @@ class MapConsentPrefsImpl( ) : MapConsentPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - private val consentFlows = atomic(persistentMapOf>()) + private val consentFlows = atomic(persistentMapOf>>()) override fun shouldReportLocation(nodeNum: Int?): StateFlow = cachedFlow(consentFlows, nodeNum) { val key = booleanPreferencesKey(nodeNum.toString()) diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt index 2292ea3ab..f3ddaad4e 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.prefs.mesh import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -45,8 +44,7 @@ class MeshPrefsImpl( ) : MeshPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - private val locationFlows = atomic(persistentMapOf>()) - private val storeForwardFlows = atomic(persistentMapOf>()) + private val storeForwardFlows = atomic(persistentMapOf>>()) override val deviceAddress: StateFlow = dataStore.data @@ -65,15 +63,6 @@ class MeshPrefsImpl( } } - override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = cachedFlow(locationFlows, nodeNum) { - val key = booleanPreferencesKey(provideLocationKey(nodeNum)) - dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) - } - - override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) { - scope.launch { dataStore.edit { prefs -> prefs[booleanPreferencesKey(provideLocationKey(nodeNum))] = provide } } - } - override fun getStoreForwardLastRequest(address: String?): StateFlow = cachedFlow(storeForwardFlows, address) { val key = intPreferencesKey(storeForwardKey(address)) dataStore.data.map { it[key] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0) @@ -92,8 +81,6 @@ class MeshPrefsImpl( } } - private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum" - private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}" companion object { diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index 7fe0da822..c0b88d385 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -46,7 +46,7 @@ class UiPrefsImpl( private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) // Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref - private val provideNodeLocationFlows = atomic(persistentMapOf>()) + private val provideNodeLocationFlows = atomic(persistentMapOf>>()) override val appIntroCompleted: StateFlow = dataStore.data.map { it[KEY_APP_INTRO_COMPLETED] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index bb32c1fbd..d7400332d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -213,10 +213,6 @@ interface MeshPrefs { fun setDeviceAddress(address: String?) - fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow - - fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) - fun getStoreForwardLastRequest(address: String?): StateFlow fun setStoreForwardLastRequest(address: String?, timestamp: Int) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index 8dcc21c71..cbaf8b3dc 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.repository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState @@ -68,12 +69,26 @@ interface RadioInterfaceService : RadioTransportCallback { /** Whether we are currently using a mock transport. */ fun isMockTransport(): Boolean - /** Flow of raw data received from the radio. */ - val receivedData: SharedFlow + /** + * Flow of raw data received from the radio. + * + * Emissions preserve the order in which bytes arrived from the hardware — this is required because the firmware + * handshake (initial config packet ordering) depends on strict FIFO delivery. Implementations MUST guarantee + * ordering; do not swap in a [SharedFlow] without preserving order. + */ + val receivedData: Flow /** Flow of radio activity events. */ val meshActivity: SharedFlow + /** + * Drains any bytes currently buffered in [receivedData] without emitting them to collectors. + * + * Callers invoke this before attaching a fresh collector after a stop/start cycle so stale bytes buffered while no + * collector was attached do not get replayed ahead of the next session's handshake. + */ + fun resetReceivedBuffer() + /** Sends a raw byte array to the radio. */ fun sendToRadio(bytes: ByteArray) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt index c6132a103..c0572f83f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt @@ -16,13 +16,11 @@ */ package org.meshtastic.core.repository -import okio.Closeable - /** * Interface for hardware transports (BLE, Serial, TCP, etc.) that handles raw byte communication. This is the * KMP-compatible replacement for the legacy Android-specific IRadioInterface. */ -interface RadioTransport : Closeable { +interface RadioTransport { /** Sends a raw byte array to the radio hardware. */ fun handleSendToRadio(p: ByteArray) @@ -39,4 +37,13 @@ interface RadioTransport : Closeable { * function can be implemented by transports to see if we are really connected. */ fun keepAlive() {} + + /** + * Closes the connection to the device. + * + * Implementations that perform potentially-blocking teardown (e.g. BLE GATT disconnect) MUST run that work inside + * `withContext(NonCancellable)` so a cancelled caller cannot skip cleanup, leaving the underlying resource leaked. + * Callers must invoke this from a coroutine — it must never be called from a blocking context (no `runBlocking`). + */ + suspend fun close() } diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt index dbc951d2a..303b8a4ad 100644 --- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt +++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt @@ -16,13 +16,14 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertTrue class RadioTransportTest { @Test - fun `RadioTransport can be implemented`() { + fun `RadioTransport can be implemented`() = runTest { var sentData: ByteArray? = null var closed = false var keepAliveCalled = false @@ -37,7 +38,7 @@ class RadioTransportTest { keepAliveCalled = true } - override fun close() { + override suspend fun close() { closed = true } } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index a958ce1ee..4748844c6 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1261,4 +1261,8 @@ Enter or select a network WiFi configured successfully! Failed to apply WiFi configuration + Meshtastic Desktop + Show Meshtastic + Quit + Meshtastic diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 50e88cc3f..ebac9f71b 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -19,13 +19,15 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.koin.core.annotation.Named +import kotlinx.coroutines.isActive import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshRouter @@ -59,18 +61,15 @@ class MeshServiceOrchestrator( private val takPrefs: TakPrefs, private val databaseManager: DatabaseManager, private val connectionManager: MeshConnectionManager, - @Named("ServiceScope") private val scope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, ) { - private var serviceJob: Job? = null - private var takJob: Job? = null - - /** The coroutine scope for the service. */ - val serviceScope: CoroutineScope - get() = scope + // Per-start coroutine scope. A fresh scope is created on each start() and cancelled on stop(), so all collectors + // launched from start() are torn down cleanly and do not accumulate across start/stop/start cycles. + private var scope: CoroutineScope? = null /** Whether the orchestrator is currently running. */ val isRunning: Boolean - get() = serviceJob?.isActive == true + get() = scope?.isActive == true /** * Starts the mesh service components and wires up data flows. @@ -85,27 +84,31 @@ class MeshServiceOrchestrator( } Logger.i { "Starting mesh service orchestrator" } - val job = Job() - serviceJob = job + val newScope = CoroutineScope(SupervisorJob() + dispatchers.default) + scope = newScope + + // Drop any bytes that piled up in the service's receivedData channel since the last stop(). The channel + // outlives the orchestrator's per-start scope, so without this drain a stop/start cycle would replay stale + // packets ahead of the fresh session's firmware handshake. + radioInterfaceService.resetReceivedBuffer() serviceNotifications.initChannels() connectionManager.updateStatusNotification() // Observe TAK server pref to start/stop - takJob = - takPrefs.isTakServerEnabled - .onEach { isEnabled -> - if (isEnabled && !takServerManager.isRunning.value) { - Logger.i { "TAK Server enabled by preference, starting integration" } - takMeshIntegration.start(scope) - } else if (!isEnabled && takServerManager.isRunning.value) { - Logger.i { "TAK Server disabled by preference, stopping integration" } - takMeshIntegration.stop() - } + takPrefs.isTakServerEnabled + .onEach { isEnabled -> + if (isEnabled && !takServerManager.isRunning.value) { + Logger.i { "TAK Server enabled by preference, starting integration" } + takMeshIntegration.start(newScope) + } else if (!isEnabled && takServerManager.isRunning.value) { + Logger.i { "TAK Server disabled by preference, stopping integration" } + takMeshIntegration.stop() } - .launchIn(scope) + } + .launchIn(newScope) - scope.handledLaunch { + newScope.handledLaunch { // Ensure the per-device database is active before the radio connects. // On Android this is handled by MeshUtilApplication.init(); on Desktop (and any // future KMP host) the orchestrator is the first entry point, so it must initialize @@ -119,18 +122,18 @@ class MeshServiceOrchestrator( radioInterfaceService.receivedData .onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum.value) } - .launchIn(scope) + .launchIn(newScope) radioInterfaceService.connectionError .onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) } - .launchIn(scope) + .launchIn(newScope) // Each action is dispatched in its own supervised coroutine so that a failure in one // action (e.g. a timeout in sendAdminAwait) cannot terminate the collector and silently // drop all subsequent service actions for the rest of the session. serviceRepository.serviceAction - .onEach { action -> scope.handledLaunch { router.actionHandler.onServiceAction(action) } } - .launchIn(scope) + .onEach { action -> newScope.handledLaunch { router.actionHandler.onServiceAction(action) } } + .launchIn(newScope) nodeManager.loadCachedNodeDB() } @@ -142,13 +145,11 @@ class MeshServiceOrchestrator( */ fun stop() { Logger.i { "Stopping mesh service orchestrator" } - takJob?.cancel() - takJob = null // Guard stop() so we don't emit a spurious "stopped" log when TAK was never started if (takServerManager.isRunning.value) { takMeshIntegration.stop() } - serviceJob?.cancel() - serviceJob = null + scope?.cancel() + scope = null } } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index df860a4a2..1bb63971c 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -25,7 +25,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -42,7 +45,7 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.ignoreException +import org.meshtastic.core.common.util.ignoreExceptionSuspend import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState @@ -95,8 +98,13 @@ class SharedRadioInterfaceService( private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value) override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() - private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64) - override val receivedData: SharedFlow = _receivedData + // Unbounded Channel preserves strict FIFO delivery of incoming radio bytes, which the + // firmware handshake depends on (initial config packet ordering). A SharedFlow with + // `launch { emit() }` per packet reorders under concurrent dispatch and breaks config load. + // trySend on an UNLIMITED channel never suspends and never drops, so handleFromRadio can + // remain a non-suspend synchronous callback. + private val _receivedData = Channel(Channel.UNLIMITED) + override val receivedData: Flow = _receivedData.receiveAsFlow() private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -148,6 +156,7 @@ class SharedRadioInterfaceService( } } } + .catch { Logger.e(it) { "devAddr flow crashed" } } .launchIn(processLifecycle.coroutineScope) bluetoothRepository.state @@ -216,7 +225,7 @@ class SharedRadioInterfaceService( processLifecycle.coroutineScope.launch { transportMutex.withLock { - ignoreException { stopTransportLocked() } + ignoreExceptionSuspend { stopTransportLocked() } startTransportLocked() } } @@ -245,7 +254,7 @@ class SharedRadioInterfaceService( } /** Must be called under [transportMutex]. */ - private fun stopTransportLocked() { + private suspend fun stopTransportLocked() { val currentTransport = radioTransport Logger.i { "Stopping transport $currentTransport" } isStarted = false @@ -322,13 +331,28 @@ class SharedRadioInterfaceService( override fun handleFromRadio(bytes: ByteArray) { try { lastDataReceivedMillis = nowMillis - processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) } + // trySend synchronously onto the unbounded Channel so packet order matches arrival + // order. The previous `launch { emit() }` pattern dispatched each packet onto a + // fresh coroutine, letting the scheduler reorder them — which broke the firmware + // config handshake (see PhoneAPI.cpp initial-handshake sequence). + val result = _receivedData.trySend(bytes) + if (result.isFailure) { + Logger.e(result.exceptionOrNull()) { "Failed to enqueue ${bytes.size} received bytes; dropping packet" } + } _meshActivity.tryEmit(MeshActivity.Receive) } catch (t: Throwable) { Logger.e(t) { "handleFromRadio failed while emitting data" } } } + override fun resetReceivedBuffer() { + // Drain any bytes buffered while no collector was attached. Without this, a stop/start cycle + // would replay stale bytes ahead of the next session's firmware handshake, since the channel + // outlives the orchestrator's per-start scope. + @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") + while (_receivedData.tryReceive().isSuccess) Unit + } + override fun onConnect() { // MutableStateFlow.value is thread-safe (backed by atomics) — assign directly rather than // launching a coroutine. The async launch pattern introduced a window where a concurrent diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index ddb7b148f..87109be1e 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -23,13 +23,15 @@ import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode.Companion.atLeast import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.CommandSender @@ -70,8 +72,11 @@ class MeshServiceOrchestratorTest { private val databaseManager: DatabaseManager = mock(MockMode.autofill) private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) + @OptIn(ExperimentalCoroutinesApi::class) private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = CoroutineScope(testDispatcher) + + @OptIn(ExperimentalCoroutinesApi::class) + private val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher) /** Stubs the shared flow dependencies used by every test and returns an orchestrator. */ private fun createOrchestrator( @@ -114,7 +119,7 @@ class MeshServiceOrchestratorTest { takPrefs = takPrefs, databaseManager = databaseManager, connectionManager = connectionManager, - scope = testScope, + dispatchers = dispatchers, ) } @@ -217,4 +222,79 @@ class MeshServiceOrchestratorTest { orchestrator.stop() assertFalse(orchestrator.isRunning) } + + /** + * Regression test for a bug where `stop()` did not actually tear down the FromRadio collectors. Collectors were + * attached to an injected process-wide ServiceScope rather than a per-start scope, so `start() -> stop() -> + * start()` caused duplicate collectors and every FromRadio packet was handled 2x (then 3x, etc.). + */ + @Test + fun testFromRadioCollectorsTornDownOnStopAndRestartedCleanlyOnStart() { + val receivedData = MutableSharedFlow(extraBufferCapacity = 8) + val orchestrator = createOrchestrator(receivedData = receivedData) + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + + orchestrator.start() + val packet1 = byteArrayOf(1, 2, 3) + receivedData.tryEmit(packet1) + verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet1, null) } + + orchestrator.stop() + val packet2 = byteArrayOf(4, 5, 6) + receivedData.tryEmit(packet2) + // After stop(), the collector must be gone - the handler should not be invoked for packet2. + verifySuspend(exactly(0)) { messageProcessor.handleFromRadio(packet2, null) } + + orchestrator.start() + val packet3 = byteArrayOf(7, 8, 9) + receivedData.tryEmit(packet3) + // After restart, a single fresh collector must process packet3 exactly once (not twice). + verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet3, null) } + + orchestrator.stop() + } + + /** + * Regression test for a channel-buffer-replay bug: the production [RadioInterfaceService] buffers inbound bytes in + * a process-lifetime `Channel(UNLIMITED)`. Between `stop()` and the next `start()`, any bytes that arrive sit in + * the channel and would be replayed to the fresh collector — prepending stale packets to the next session's + * firmware handshake. `start()` must call [RadioInterfaceService.resetReceivedBuffer] before attaching the + * collector. + */ + @Test + fun testStartDrainsReceivedBufferBeforeAttachingCollector() { + val orchestrator = createOrchestrator() + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + + orchestrator.start() + orchestrator.stop() + orchestrator.start() + + // resetReceivedBuffer must be invoked at least once per start() (twice total for two starts). + verify(atLeast(2)) { radioInterfaceService.resetReceivedBuffer() } + + orchestrator.stop() + } + + /** Additional regression: after many start/stop cycles, collectors must not accumulate. */ + @Test + fun testRepeatedStartStopDoesNotAccumulateCollectors() { + val receivedData = MutableSharedFlow(extraBufferCapacity = 8) + val orchestrator = createOrchestrator(receivedData = receivedData) + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + + repeat(5) { + orchestrator.start() + orchestrator.stop() + } + + orchestrator.start() + val packet = byteArrayOf(42) + receivedData.tryEmit(packet) + + // Despite six total start() calls, only the most recent collector is live. + verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet, null) } + + orchestrator.stop() + } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt index 31248ec41..0a47321d6 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt @@ -18,12 +18,15 @@ package org.meshtastic.core.takserver import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -58,6 +61,12 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager private val _inboundMessages = MutableSharedFlow() override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow() + // Unbounded channel preserves FIFO ordering of inbound CoT messages under load. + // onMessage is a non-suspend callback, so we trySend (always succeeds for UNLIMITED) + // and a single consumer coroutine drains into _inboundMessages in order. + private var inboundChannel: Channel? = null + private var inboundDrainJob: Job? = null + private var lastBroadcastPositions = mutableMapOf() override fun start(scope: CoroutineScope) { @@ -68,8 +77,11 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager } scope.launch { - // Wire up inbound message handler BEFORE starting so no messages are lost - takServer.onMessage = { cotMessage -> scope.launch { _inboundMessages.emit(cotMessage) } } + // Wire up inbound message handler BEFORE starting so no messages are lost. + val channel = Channel(Channel.UNLIMITED) + inboundChannel = channel + inboundDrainJob = scope.launch { channel.consumeAsFlow().collect { _inboundMessages.emit(it) } } + takServer.onMessage = { cotMessage -> channel.trySend(cotMessage) } val result = takServer.start(scope) if (result.isSuccess) { @@ -79,6 +91,10 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" } // Clear onMessage if start failed so we don't hold a reference unnecessarily takServer.onMessage = null + inboundDrainJob?.cancel() + inboundDrainJob = null + channel.close() + inboundChannel = null } } } @@ -86,6 +102,10 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager override fun stop() { takServer.stop() takServer.onMessage = null + inboundChannel?.close() + inboundChannel = null + inboundDrainJob?.cancel() + inboundDrainJob = null _isRunning.value = false scope = null Logger.i { "TAK Server stopped" } diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 25e1a3d91..8d0b5837a 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -32,8 +32,8 @@ kotlin { // Heavy modules (database, data, domain) should depend on core:testing, not vice versa. api(projects.core.model) api(projects.core.repository) - api(projects.core.database) - api(projects.core.ble) + implementation(projects.core.database) + implementation(projects.core.ble) implementation(projects.core.datastore) implementation(libs.androidx.room.runtime) api(libs.kermit) diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 9a703004c..0eb120fbe 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -237,15 +237,6 @@ class FakeMeshPrefs : MeshPrefs { deviceAddress.value = address } - private val provideLocation = mutableMapOf>() - - override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = - provideLocation.getOrPut(nodeNum) { MutableStateFlow(true) } - - override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) { - provideLocation.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide - } - private val lastRequest = mutableMapOf>() override fun getStoreForwardLastRequest(address: String?): StateFlow = diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt index 9f11a2bc6..d3f8dc71e 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt @@ -18,10 +18,13 @@ package org.meshtastic.core.testing import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId @@ -48,8 +51,10 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main private val _currentDeviceAddressFlow = MutableStateFlow(null) override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow - private val _receivedData = MutableSharedFlow() - override val receivedData: SharedFlow = _receivedData + // Use an unbounded Channel to mirror SharedRadioInterfaceService semantics. A MutableSharedFlow would + // hide the stop/start backlog bug that motivated the resetReceivedBuffer() API. + private val _receivedData = Channel(Channel.UNLIMITED) + override val receivedData: Flow = _receivedData.receiveAsFlow() private val _meshActivity = MutableSharedFlow() override val meshActivity: SharedFlow = _meshActivity @@ -88,13 +93,18 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main } override fun handleFromRadio(bytes: ByteArray) { - // In a real implementation, this would emit to receivedData + _receivedData.trySend(bytes) + } + + override fun resetReceivedBuffer() { + @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") + while (_receivedData.tryReceive().isSuccess) Unit } // --- Helper methods for testing --- - suspend fun emitFromRadio(bytes: ByteArray) { - _receivedData.emit(bytes) + fun emitFromRadio(bytes: ByteArray) { + _receivedData.trySend(bytes) } fun setConnectionState(state: ConnectionState) { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt index 66afa69be..492802426 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt @@ -32,7 +32,7 @@ class FakeRadioTransport : RadioTransport { keepAliveCalled = true } - override fun close() { + override suspend fun close() { closeCalled = true } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index fdf7cee5c..58caf800b 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -127,7 +127,10 @@ compose.desktop { isEnabled.set(true) obfuscate.set(false) // Open-source project — obfuscation adds no value optimize.set(true) - configurationFiles.from(project.file("proguard-rules.pro")) + configurationFiles.from( + rootProject.file("config/proguard/shared-rules.pro"), + project.file("proguard-rules.pro"), + ) } nativeDistributions { diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index ef1576555..9e23e32c7 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -4,202 +4,56 @@ # Open-source project: we rely on tree-shaking (unused code removal) for size # reduction. Obfuscation is disabled in build.gradle.kts (obfuscate.set(false)). # -# Key libraries requiring keep-rules (reflection, JNI, code generation): -# Koin (DI via reflection), kotlinx-serialization (generated serializers), -# Wire protobuf (ADAPTER reflection), Room KMP (generated DB + converters), -# Ktor (Java engine + ServiceLoader), Kable BLE, Coil, Compose Multiplatform -# resources, SQLite bundled (JNI), AboutLibraries. +# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, +# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, +# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in +# config/proguard/shared-rules.pro and are wired in by this module's +# build.gradle.kts. This file holds only desktop/JVM-specific rules. # ============================================================================ # ---- General ---------------------------------------------------------------- -# Preserve line numbers for meaningful stack traces --keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions - # Suppress notes about duplicate resource files (common in fat JARs) -dontnote ** +# Disable ProGuard optimization passes. Tree-shaking (unused code removal) still +# runs — only method-body rewrites and call-site transformations are suppressed. +# +# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on +# Composer.() and ComposerImpl.(), plus -assumevalues on +# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives +# let the optimizer rewrite *call sites* (class-init triggers, flag reads) even +# when the target classes are preserved by -keep rules. The result is that the +# Compose recomposer/frame-clock/animation state machines silently freeze on +# their first frame in release builds. -dontoptimize is the only directive that +# disables processing of -assumenosideeffects/-assumevalues. The desktop compose +# build sets optimize.set(true), so this applies here as well as to R8. See #5146. +-dontoptimize + # Do not parse/rewrite Kotlin metadata during shrinking/optimization. # ProGuard's KotlinShrinker cannot handle the metadata produced by Compose # Multiplatform 1.11.x + Kotlin 2.3.x, causing a NullPointerException. # Since we disable obfuscation (class names remain stable), metadata references # stay valid and do not need rewriting. The annotations themselves are preserved # by -keepattributes *Annotation*. +# +# NOTE: -dontprocesskotlinmetadata is a ProGuard-only directive; R8 does not +# recognize it, which is why it lives in the desktop-only file. -dontprocesskotlinmetadata # ---- Entry point ------------------------------------------------------------ -keep class org.meshtastic.desktop.MainKt { *; } -# ---- Kotlin / Coroutines --------------------------------------------------- +# ---- Ktor Java engine (desktop-only; Android uses OkHttp) ------------------- -# Keep Kotlin metadata for reflection-dependent libraries --keep class kotlin.Metadata { *; } --keep class kotlin.reflect.** { *; } - -# Coroutines internals --dontwarn kotlinx.coroutines.** --keep class kotlinx.coroutines.** { *; } --keep class kotlin.coroutines.Continuation { *; } - -# ---- Koin DI (reflection-based injection) ----------------------------------- - -# Koin core — uses reflection to instantiate definitions --keep class org.koin.** { *; } --dontwarn org.koin.** - -# Keep all Koin-annotated @Module / @ComponentScan classes and their generated -# counterparts so Koin K2 plugin output survives tree-shaking. --keep @org.koin.core.annotation.Module class * { *; } --keep @org.koin.core.annotation.ComponentScan class * { *; } --keep @org.koin.core.annotation.Single class * { *; } --keep @org.koin.core.annotation.Factory class * { *; } - -# Generated Koin module extensions (K2 plugin output) --keep class org.meshtastic.**.di.** { *; } - -# ---- kotlinx-serialization -------------------------------------------------- - -# The serialization plugin generates companion $serializer classes and -# serializer() factory methods that are invoked reflectively. --keepattributes RuntimeVisibleAnnotations --keep class kotlinx.serialization.** { *; } --dontwarn kotlinx.serialization.** - -# Keep @Serializable classes and their generated serializers --keepclassmembers @kotlinx.serialization.Serializable class ** { - # Companion object that holds the serializer() factory - static ** Companion; - kotlinx.serialization.KSerializer serializer(...); -} --keepclassmembers class **.$serializer { *; } --keep class **.$serializer { *; } --keepclasseswithmembers class ** { - kotlinx.serialization.KSerializer serializer(...); -} - -# ---- Wire protobuf ---------------------------------------------------------- - -# Wire generates ADAPTER companion objects accessed via reflection --keep class com.squareup.wire.** { *; } --dontwarn com.squareup.wire.** - -# All generated proto message classes --keep class org.meshtastic.proto.** { *; } --keep class meshtastic.** { *; } - -# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs) --dontwarn android.os.Parcel** --dontwarn android.os.Parcelable** - -# ---- Room KMP --------------------------------------------------------------- - -# Preserve generated database constructors (required for Room's reflective init) --keep class * extends androidx.room3.RoomDatabase { (); } --keep class * implements androidx.room3.RoomDatabaseConstructor { *; } - -# Keep the expect/actual MeshtasticDatabaseConstructor --keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } --keep class org.meshtastic.core.database.MeshtasticDatabase { *; } - -# Room DAOs — Room generates implementations at compile time; keep interfaces --keep class org.meshtastic.core.database.dao.** { *; } - -# Room Entities — accessed via reflection for column mapping --keep class org.meshtastic.core.database.entity.** { *; } - -# Room TypeConverters — invoked reflectively --keep class org.meshtastic.core.database.Converters { *; } - -# Room generated _Impl classes --keep class **_Impl { *; } - -# ---- SQLite bundled (JNI) --------------------------------------------------- - --keep class androidx.sqlite.** { *; } --dontwarn androidx.sqlite.** - -# ---- Ktor (Java engine + ServiceLoader + content negotiation) --------------- - -# Ktor uses ServiceLoader and reflection for engine/plugin discovery --keep class io.ktor.** { *; } --dontwarn io.ktor.** - -# Keep ServiceLoader metadata files --keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } - -# Java HTTP client engine -keep class io.ktor.client.engine.java.** { *; } -# ---- Coil (image loading) --------------------------------------------------- - --keep class coil3.** { *; } --dontwarn coil3.** - -# ---- Kable BLE -------------------------------------------------------------- - --keep class com.juul.kable.** { *; } --dontwarn com.juul.kable.** - -# ---- Compose Multiplatform resources ---------------------------------------- - -# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.) --keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.** { *; } - - -# ---- AboutLibraries --------------------------------------------------------- - --keep class com.mikepenz.aboutlibraries.** { *; } --dontwarn com.mikepenz.aboutlibraries.** - -# ---- Multiplatform Markdown Renderer ---------------------------------------- - --keep class com.mikepenz.markdown.** { *; } --dontwarn com.mikepenz.markdown.** - -# ---- QR Code Kotlin --------------------------------------------------------- - --keep class io.github.g0dkar.qrcode.** { *; } --dontwarn io.github.g0dkar.qrcode.** --keep class qrcode.** { *; } --dontwarn qrcode.** - -# ---- Kermit logging ---------------------------------------------------------- - --keep class co.touchlab.kermit.** { *; } --dontwarn co.touchlab.kermit.** - -# ---- Okio ------------------------------------------------------------------- - --dontwarn okio.** --keep class okio.** { *; } - -# ---- DataStore -------------------------------------------------------------- - --keep class androidx.datastore.** { *; } --dontwarn androidx.datastore.** - -# ---- Paging ----------------------------------------------------------------- - --keep class androidx.paging.** { *; } --dontwarn androidx.paging.** - -# ---- Lifecycle / Navigation / ViewModel (JetBrains forks) ------------------- - --keep class androidx.lifecycle.** { *; } --keep class androidx.navigation3.** { *; } --dontwarn androidx.lifecycle.** --dontwarn androidx.navigation3.** - -# ---- Meshtastic application code -------------------------------------------- +# ---- Meshtastic desktop host shell ------------------------------------------ # Keep all desktop module classes (thin host shell — not worth tree-shaking) -keep class org.meshtastic.desktop.** { *; } -# Core model classes (used in serialization, Room, and Koin injection) --keep class org.meshtastic.core.model.** { *; } - # ---- JVM runtime suppression ------------------------------------------------ -dontwarn java.lang.reflect.** diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 80e049bce..026f0a100 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -60,6 +60,7 @@ import io.ktor.client.HttpClient import kotlinx.coroutines.flow.first import okio.Path.Companion.toPath import org.jetbrains.compose.resources.decodeToSvgPainter +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.core.context.startKoin import org.meshtastic.core.common.BuildConfigProvider @@ -70,6 +71,10 @@ import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.desktop_tray_quit +import org.meshtastic.core.resources.desktop_tray_show +import org.meshtastic.core.resources.desktop_tray_tooltip import org.meshtastic.core.service.MeshServiceOrchestrator import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.viewmodel.UIViewModel @@ -216,11 +221,11 @@ private fun ApplicationScope.MeshtasticDesktopApp( Tray( state = trayState, icon = trayIcon, - tooltip = "Meshtastic Desktop", + tooltip = stringResource(Res.string.desktop_tray_tooltip), onAction = { isAppVisible = true }, menu = { - Item("Show Meshtastic", onClick = { isAppVisible = true }) - Item("Quit", onClick = ::exitApplication) + Item(stringResource(Res.string.desktop_tray_show), onClick = { isAppVisible = true }) + Item(stringResource(Res.string.desktop_tray_quit), onClick = ::exitApplication) }, ) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index 309fff7da..4cda00251 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -22,6 +22,7 @@ import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.desktop_notification_title import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.low_battery_message import org.meshtastic.core.resources.low_battery_title @@ -141,7 +142,7 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat override fun showClientNotification(clientNotification: ClientNotification) { notificationManager.dispatch( Notification( - title = "Meshtastic", + title = getString(Res.string.desktop_notification_title), message = clientNotification.message, category = Notification.Category.Alert, id = clientNotification.toString().hashCode(), 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 220b21d05..985a76987 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -80,6 +80,10 @@ class NoopRadioInterfaceService : RadioInterfaceService { logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)") } + override fun resetReceivedBuffer() { + // No-op: this stub never buffers bytes. + } + override fun connect() { logWarn("NoopRadioInterfaceService.connect()") } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt index ecdaeb3c3..4249cd625 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt @@ -18,14 +18,15 @@ package org.meshtastic.feature.connections.domain.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.util.safeCatchingAll import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.demo_mode +import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.meshtastic import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.DiscoveredDevices @@ -49,7 +50,7 @@ class CommonGetDiscoveredDevicesUseCase( tcpServices, recentList, -> - val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic") + val defaultName = safeCatchingAll { getStringSuspend(Res.string.meshtastic) }.getOrDefault("Meshtastic") processTcpServices(tcpServices, recentList, defaultName) } @@ -71,7 +72,7 @@ class CommonGetDiscoveredDevicesUseCase( usbList + if (showMock) { val demoModeLabel = - runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode") + safeCatchingAll { getStringSuspend(Res.string.demo_mode) }.getOrDefault("Demo Mode") listOf(DeviceListEntry.Mock(demoModeLabel)) } else { emptyList() diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index 8499d4e20..a4d8ecdd8 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,15 +76,11 @@ fun DeviceListItem( ) { // Throttle the RSSI updates to match the connected device polling rate var displayedRssi by remember { mutableIntStateOf(rssi ?: 0) } - LaunchedEffect(rssi) { - if (displayedRssi == 0) { - displayedRssi = rssi ?: 0 - } - } + val currentRssi by rememberUpdatedState(rssi) LaunchedEffect(Unit) { while (true) { delay(RSSI_UPDATE_RATE_MS) - displayedRssi = rssi ?: 0 + displayedRssi = currentRssi ?: 0 } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt index 8565b3dcc..8035774c4 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -48,7 +47,7 @@ class BleOtaTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) : UnifiedOtaProtocol { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 58c09f16a..82e91413d 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository @@ -67,7 +68,7 @@ private const val GATT_RELEASE_DELAY_MS = 1000L * * All platform I/O (file reading, content-resolver imports) is delegated to [FirmwareFileHandler]. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @Single class Esp32OtaUpdateHandler( private val firmwareRetriever: FirmwareRetriever, @@ -76,6 +77,7 @@ class Esp32OtaUpdateHandler( private val nodeRepository: NodeRepository, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { /** Entry point for FirmwareUpdateHandler interface. Routes to BLE (MAC with colons) or WiFi (IP without). */ @@ -102,7 +104,7 @@ class Esp32OtaUpdateHandler( hardware = hardware, updateState = updateState, firmwareUri = firmwareUri, - transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address) }, + transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatchers.default) }, rebootMode = 1, connectionAttempts = 5, ) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt index 53e8ed977..d21cc15ea 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt @@ -167,7 +167,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In override suspend fun close() { withContext(ioDispatcher) { - runCatching { + safeCatching { socket?.close() selectorManager?.close() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index 3e673461b..a2eb5a7a4 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -28,6 +28,7 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.resources.Res @@ -70,6 +71,7 @@ class SecureDfuHandler( private val radioController: RadioController, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { @Suppress("LongMethod") @@ -108,7 +110,7 @@ class SecureDfuHandler( var transport: SecureDfuTransport? = null var completed = false try { - transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target) + transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) transport.triggerButtonlessDfu().onFailure { e -> Logger.w(e) { "DFU: Buttonless trigger failed ($e) — device may already be in DFU mode" } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index 10320e6e5..42e92c8ac 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel @@ -67,7 +66,7 @@ class SecureDfuTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) private val bleConnection = connectionFactory.create(transportScope, "Secure DFU") @@ -252,7 +251,7 @@ class SecureDfuTransport( * accept a fresh DFU session. */ suspend fun abort() { - runCatching { + safeCatching { bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) service.write(controlChar, byteArrayOf(DfuOpcode.ABORT), BleWriteType.WITH_RESPONSE) @@ -264,7 +263,7 @@ class SecureDfuTransport( /** Disconnect from the DFU target and cancel the transport coroutine scope. */ suspend fun close() { - runCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } + safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } transportScope.cancel() } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt index 723fed82f..0a26fd13e 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt @@ -20,9 +20,11 @@ import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository @@ -59,6 +61,12 @@ class DefaultFirmwareUpdateManagerTest { private val bleScanner: BleScanner = mock(MockMode.autofill) private val bleConnectionFactory: BleConnectionFactory = mock(MockMode.autofill) private val firmwareRetriever = FirmwareRetriever(fileHandler) + private val dispatchers = + CoroutineDispatchers( + io = Dispatchers.Unconfined, + main = Dispatchers.Unconfined, + default = Dispatchers.Unconfined, + ) private val secureDfuHandler = SecureDfuHandler( @@ -67,6 +75,7 @@ class DefaultFirmwareUpdateManagerTest { radioController = radioController, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, + dispatchers = dispatchers, ) private val usbUpdateHandler = @@ -84,6 +93,7 @@ class DefaultFirmwareUpdateManagerTest { nodeRepository = nodeRepository, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, + dispatchers = dispatchers, ) private fun createManager(address: String?): DefaultFirmwareUpdateManager { diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 2793f3625..c33a6f353 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.meshtastic.kmp.feature) alias(libs.plugins.meshtastic.kotlinx.serialization) + id("meshtastic.kmp.jvm.android") } kotlin { diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt index 499874e26..723448897 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt @@ -23,7 +23,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -private const val SDK_INT_ANDROID_16 = 37 +private val SDK_INT_ANDROID_16 = Build.VERSION_CODES.BAKLAVA @OptIn(ExperimentalPermissionsApi::class) @Composable diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt similarity index 100% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt rename to feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt deleted file mode 100644 index 0a35599f5..000000000 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings.navigation - -import org.meshtastic.core.navigation.SettingsRoute - -actual fun getAboutLibrariesJson(): String = - SettingsRoute::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" 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 793482ba2..ee40bd60b 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 @@ -26,14 +26,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.LocalStats @@ -78,12 +76,10 @@ data class LocalStatsWidgetUiState( val updateTimeMillis: Long = 0, ) +private const val WIDGET_SUBSCRIPTION_TIMEOUT_MS = 5_000L + @Single -class LocalStatsWidgetStateProvider( - nodeRepository: NodeRepository, - serviceRepository: ServiceRepository, - appWidgetUpdater: AppWidgetUpdater, -) { +class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -105,8 +101,11 @@ class LocalStatsWidgetStateProvider( mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode) } .distinctUntilChanged() - .onEach { appWidgetUpdater.updateAll() } - .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState()) + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(WIDGET_SUBSCRIPTION_TIMEOUT_MS), + initialValue = LocalStatsWidgetUiState(), + ) private data class StateInput( val connectionState: ConnectionState, diff --git a/feature/wifi-provision/build.gradle.kts b/feature/wifi-provision/build.gradle.kts index 3ce123dec..84b8199a5 100644 --- a/feature/wifi-provision/build.gradle.kts +++ b/feature/wifi-provision/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { commonMain.dependencies { implementation(projects.core.ble) implementation(projects.core.common) + implementation(projects.core.di) implementation(projects.core.navigation) implementation(projects.core.resources) implementation(projects.core.ui) diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt index 9e1177be8..6dbb8c676 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.feature.wifiprovision.domain.NymeaWifiService import org.meshtastic.feature.wifiprovision.model.ProvisionResult import org.meshtastic.feature.wifiprovision.model.WifiNetwork @@ -106,6 +107,7 @@ sealed interface WifiProvisionError { class WifiProvisionViewModel( private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : ViewModel() { private val _uiState = MutableStateFlow(WifiProvisionUiState()) @@ -127,7 +129,7 @@ class WifiProvisionViewModel( _uiState.update { it.copy(phase = WifiProvisionUiState.Phase.ConnectingBle, error = null) } viewModelScope.launch { - val nymeaService = NymeaWifiService(bleScanner, bleConnectionFactory) + val nymeaService = NymeaWifiService(bleScanner, bleConnectionFactory, dispatchers.default) service = nymeaService nymeaService diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt index 1723e6df6..75dc15256 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -67,7 +66,7 @@ import org.meshtastic.feature.wifiprovision.model.WifiNetwork class NymeaWifiService( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) { private val serviceScope = CoroutineScope(SupervisorJob() + dispatcher) diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt index 65798a13b..0ee5bb0ec 100644 --- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.testing.FakeBleConnection import org.meshtastic.core.testing.FakeBleConnectionFactory import org.meshtastic.core.testing.FakeBleDevice @@ -62,7 +63,15 @@ class WifiProvisionViewModelTest { scanner = FakeBleScanner() connection = FakeBleConnection() viewModel = - WifiProvisionViewModel(bleScanner = scanner, bleConnectionFactory = FakeBleConnectionFactory(connection)) + WifiProvisionViewModel( + bleScanner = scanner, + bleConnectionFactory = FakeBleConnectionFactory(connection), + dispatchers = CoroutineDispatchers( + io = testDispatcher, + main = testDispatcher, + default = testDispatcher, + ), + ) } @AfterTest diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ecd560a8..668ed133a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,11 +34,6 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" -# Skiko is an internal CMP implementation detail. Pin it to the version shipped by CMP to -# silence the "Skiko dependencies' versions are incompatible" warning emitted when transitive -# dependencies (e.g. coil3) carry an older skiko requirement that Gradle then upgrades to the -# CMP-bundled version. Bump this together with compose-multiplatform. -skiko = "0.144.5" compose-multiplatform-material3 = "1.11.0-alpha06" # AndroidX Compose test/tracing artifacts share a version track with CMP but are resolved # independently by Maven. Pinning them to their own ref prevents Renovate from bumping the @@ -153,7 +148,6 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.12.0 firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } location-services = { module = "com.google.android.gms:play-services-location", version = "21.3.0" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } -koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } @@ -257,7 +251,6 @@ koin-gradlePlugin = { module = "io.insert-koin.compiler.plugin:io.insert-koin.co mokkery-gradlePlugin = { module = "dev.mokkery:mokkery-gradle", version.ref = "mokkery" } kover-gradlePlugin = { module = "org.jetbrains.kotlinx.kover:org.jetbrains.kotlinx.kover.gradle.plugin", version.ref = "kover" } ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "devtools-ksp" } -secrets-gradlePlugin = {module = "com.google.android.secrets-gradle-plugin:com.google.android.secrets-gradle-plugin.gradle.plugin", version = "1.1.0"} serialization-gradlePlugin = { module = "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin", version.ref = "kotlin" } spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" } @@ -300,14 +293,12 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" } # Meshtastic -meshtastic-analytics = { id = "meshtastic.analytics" } meshtastic-android-application = { id = "meshtastic.android.application" } meshtastic-android-application-compose = { id = "meshtastic.android.application.compose" } meshtastic-android-application-flavors = { id = "meshtastic.android.application.flavors" } meshtastic-android-library = { id = "meshtastic.android.library" } meshtastic-android-library-compose = { id = "meshtastic.android.library.compose" } meshtastic-android-library-flavors = { id = "meshtastic.android.library.flavors" } -meshtastic-android-lint = { id = "meshtastic.android.lint" } meshtastic-android-room = { id = "meshtastic.android.room" } meshtastic-detekt = { id = "meshtastic.detekt" } meshtastic-koin = { id = "meshtastic.koin" } 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 09/38] 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 10/38] 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 11/38] 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 12/38] 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 13/38] 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 14/38] 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 15/38] 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 16/38] test: migrate MigrationTest to runTest and add missing repository fakes (#5171) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pr5167.diff | 295 ++++++++++++++++++ .../core/database/dao/MigrationTest.kt | 12 +- .../testing/FakeDeviceHardwareRepository.kt | 69 ++++ .../testing/FakeFirmwareReleaseRepository.kt | 57 ++++ .../testing/FakeQuickChatActionRepository.kt | 71 +++++ .../core/testing/FakeRadioConfigRepository.kt | 162 ++++++++++ .../FakeTracerouteSnapshotRepository.kt | 55 ++++ .../core/testing/RepositoryFakesTest.kt | 129 ++++++++ 8 files changed, 844 insertions(+), 6 deletions(-) create mode 100644 .pr5167.diff create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt create mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt create mode 100644 core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt diff --git a/.pr5167.diff b/.pr5167.diff new file mode 100644 index 000000000..d0a809449 --- /dev/null +++ b/.pr5167.diff @@ -0,0 +1,295 @@ +diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +new file mode 100644 +index 0000000000..2a27b96906 +--- /dev/null ++++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +@@ -0,0 +1,39 @@ ++/* ++ * Copyright (c) 2026 Meshtastic LLC ++ * ++ * This program is free software: you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation, either version 3 of the License, or ++ * (at your option) any later version. ++ * ++ * This program is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU General Public License for more details. ++ * ++ * You should have received a copy of the GNU General Public License ++ * along with this program. If not, see . ++ */ ++package org.meshtastic.core.common.di ++ ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.SupervisorJob ++import org.koin.core.annotation.Single ++import org.meshtastic.core.common.util.ioDispatcher ++ ++/** ++ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. ++ * ++ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled ++ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not ++ * cancel siblings, and by [ioDispatcher] so work runs off the main thread. ++ * ++ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch ++ * and should be used sparingly. ++ */ ++interface ApplicationCoroutineScope : CoroutineScope ++ ++@Single(binds = [ApplicationCoroutineScope::class]) ++internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { ++ override val coroutineContext = SupervisorJob() + ioDispatcher ++} +diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +index 231c84d401..5365ab95e2 100644 +--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt ++++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect + import co.touchlab.kermit.Logger + import com.eygraber.uri.toAndroidUri + import com.eygraber.uri.toKmpUri +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.jetbrains.compose.resources.StringResource + import org.jetbrains.compose.resources.getString + import org.meshtastic.core.common.gpsDisabled + import org.meshtastic.core.common.util.CommonUri ++import org.meshtastic.core.common.util.ioDispatcher + import java.net.URLEncoder + + @Composable +@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> + val context = LocalContext.current + return remember(context) { + { uri, maxChars -> +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + val androidUri = uri.toAndroidUri() +diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +index 031e1fe35d..a938f92ea6 100644 +--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt ++++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util + + import androidx.compose.runtime.Composable + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.withContext + import org.jetbrains.compose.resources.StringResource + import org.meshtastic.core.common.util.CommonUri ++import org.meshtastic.core.common.util.ioDispatcher + import java.awt.Desktop + import java.awt.FileDialog + import java.awt.Frame +@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT + /** JVM — Reads text from a file URI. */ + @Composable + actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + @Suppress("TooGenericExceptionCaught") + try { + val file = File(URI(uri.toString())) +diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +index dc1c459716..f8ff9fcac8 100644 +--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt ++++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch + import kotlinx.coroutines.withTimeoutOrNull + import org.jetbrains.compose.resources.StringResource + import org.koin.core.annotation.KoinViewModel ++import org.meshtastic.core.common.di.ApplicationCoroutineScope + import org.meshtastic.core.common.util.CommonUri + import org.meshtastic.core.common.util.safeCatching + import org.meshtastic.core.database.entity.FirmwareRelease +@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel( + private val firmwareUpdateManager: FirmwareUpdateManager, + private val usbManager: FirmwareUsbManager, + private val fileHandler: FirmwareFileHandler, ++ private val applicationScope: ApplicationCoroutineScope, + ) : ViewModel() { + + private val _state = MutableStateFlow(FirmwareUpdateState.Idle) +@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel( + + override fun onCleared() { + super.onCleared() +- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a +- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a +- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope +- // is cancelled concurrently. +- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) +- kotlinx.coroutines.GlobalScope.launch(NonCancellable) { ++ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the ++ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup ++ // running even if something tries to cancel it mid-flight. ++ applicationScope.launch(NonCancellable) { + tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) + } + } +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +index 4c48a1ced5..030d84effd 100644 +--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + @Test +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +index 7032ed4088..a8eddff838 100644 +--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + @Test +diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +new file mode 100644 +index 0000000000..3ef5c44ef4 +--- /dev/null ++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +@@ -0,0 +1,26 @@ ++/* ++ * Copyright (c) 2026 Meshtastic LLC ++ * ++ * This program is free software: you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License as published by ++ * the Free Software Foundation, either version 3 of the License, or ++ * (at your option) any later version. ++ * ++ * This program is distributed in the hope that it will be useful, ++ * but WITHOUT ANY WARRANTY; without even the implied warranty of ++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ * GNU General Public License for more details. ++ * ++ * You should have received a copy of the GNU General Public License ++ * along with this program. If not, see . ++ */ ++package org.meshtastic.feature.firmware ++ ++import kotlinx.coroutines.CoroutineDispatcher ++import kotlinx.coroutines.CoroutineScope ++import kotlinx.coroutines.SupervisorJob ++import org.meshtastic.core.common.di.ApplicationCoroutineScope ++ ++internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : ++ ApplicationCoroutineScope, ++ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) +diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +index acb1545bdd..23a0d03ab2 100644 +--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt ++++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { + firmwareUpdateManager, + usbManager, + fileHandler, ++ TestApplicationCoroutineScope(testDispatcher), + ) + + // ----------------------------------------------------------------------- +diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +index c251b4d5ef..315ad1da85 100644 +--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt ++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger + import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + import org.meshtastic.core.resources.Res + import org.meshtastic.core.resources.debug_export_failed + import org.meshtastic.core.resources.debug_export_success +@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = +- withContext(Dispatchers.IO) { ++ withContext(ioDispatcher) { + try { + if (logs.isEmpty()) { + withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } +diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +index 9afde85e5f..a28a576788 100644 +--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt ++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable + import androidx.compose.runtime.rememberCoroutineScope + import androidx.compose.ui.platform.LocalContext + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + + @Composable + actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { +@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr + return { fileName -> exportLauncher.launch(fileName) } + } + +-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { ++private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { + try { + context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } + Logger.i { "TAK data package exported successfully to $targetUri" } +diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +index 5b63cc90a3..a9a7285593 100644 +--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt ++++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging + import androidx.compose.runtime.Composable + import androidx.compose.runtime.rememberCoroutineScope + import co.touchlab.kermit.Logger +-import kotlinx.coroutines.Dispatchers + import kotlinx.coroutines.launch + import kotlinx.coroutines.withContext ++import org.meshtastic.core.common.util.ioDispatcher + import java.awt.FileDialog + import java.awt.Frame + import java.io.File +@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr + if (directory != null && file != null) { + val targetFile = File(directory, file) + val data = dataPackageProvider() +- withContext(Dispatchers.IO) { targetFile.writeBytes(data) } ++ withContext(ioDispatcher) { targetFile.writeBytes(data) } + Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } + } + } diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 8062afa76..451a62174 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -20,7 +20,7 @@ import androidx.room3.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Before @@ -59,7 +59,7 @@ class MigrationTest { ) @Before - fun createDb(): Unit = runBlocking { + fun createDb(): Unit = runTest { val context = ApplicationProvider.getApplicationContext() database = Room.inMemoryDatabaseBuilder( @@ -77,7 +77,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking { + fun testMigrateChannelsByPSK_duplicatePSK() = runTest { // PSK \"AQ==\" is base64 for single byte 0x01 val pskBytes = byteArrayOf(0x01).toByteString() @@ -103,7 +103,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_reorder() = runBlocking { + fun testMigrateChannelsByPSK_reorder() = runTest { val pskA = byteArrayOf(0x01).toByteString() val pskB = byteArrayOf(0x02).toByteString() @@ -122,7 +122,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking { + fun testMigrateChannelsByPSK_disambiguateByName() = runTest { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A1") @@ -141,7 +141,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking { + fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A") diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt new file mode 100644 index 000000000..ef8cac0ba --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.repository.DeviceHardwareRepository + +/** + * A test double for [DeviceHardwareRepository] backed by an in-memory map keyed by `(hwModel, target)`. + * + * Call [setHardware] (or [setHardwareForModel]) to seed results, or [setResult] to control the exact [Result] returned + * for a given lookup. By default, lookups return `Result.success(null)`. + */ +class FakeDeviceHardwareRepository : + BaseFake(), + DeviceHardwareRepository { + + private val hardware = mutableMapOf, Result>() + private val calls = mutableListOf>() + + init { + registerResetAction { + hardware.clear() + calls.clear() + } + } + + /** Records every [getDeviceHardwareByModel] invocation for assertion. */ + val recordedCalls: List> + get() = calls.toList() + + override suspend fun getDeviceHardwareByModel( + hwModel: Int, + target: String?, + forceRefresh: Boolean, + ): Result { + calls.add(Triple(hwModel, target, forceRefresh)) + return hardware[hwModel to target] ?: hardware[hwModel to null] ?: Result.success(null) + } + + /** Seeds a successful lookup for the given model/target pair. */ + fun setHardware(hwModel: Int, target: String? = null, device: DeviceHardware?) { + hardware[hwModel to target] = Result.success(device) + } + + /** Seeds a successful lookup for any target of the given model. */ + fun setHardwareForModel(hwModel: Int, device: DeviceHardware?) { + hardware[hwModel to null] = Result.success(device) + } + + /** Seeds an arbitrary [Result] for the given lookup (use to test failure paths). */ + fun setResult(hwModel: Int, target: String? = null, result: Result) { + hardware[hwModel to target] = result + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt new file mode 100644 index 000000000..166256764 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.repository.FirmwareReleaseRepository + +/** + * A test double for [FirmwareReleaseRepository] that exposes stable and alpha releases as + * [kotlinx.coroutines.flow.MutableStateFlow]s. + * + * Use [setStableRelease] and [setAlphaRelease] to drive the emitted values. + */ +class FakeFirmwareReleaseRepository : + BaseFake(), + FirmwareReleaseRepository { + + private val _stableRelease = mutableStateFlow(null) + private val _alphaRelease = mutableStateFlow(null) + + override val stableRelease: Flow = _stableRelease + override val alphaRelease: Flow = _alphaRelease + + var invalidateCacheCalls: Int = 0 + private set + + init { + registerResetAction { invalidateCacheCalls = 0 } + } + + override suspend fun invalidateCache() { + invalidateCacheCalls++ + } + + fun setStableRelease(release: FirmwareRelease?) { + _stableRelease.value = release + } + + fun setAlphaRelease(release: FirmwareRelease?) { + _alphaRelease.value = release + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt new file mode 100644 index 000000000..215542485 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.repository.QuickChatActionRepository + +/** + * A test double for [QuickChatActionRepository] that keeps actions in an in-memory list (sorted by `position`). + * + * The in-memory list is exposed reactively through [getAllActions]. + */ +class FakeQuickChatActionRepository : + BaseFake(), + QuickChatActionRepository { + + private val actionsFlow = mutableStateFlow>(emptyList()) + + override fun getAllActions(): Flow> = actionsFlow + + override suspend fun upsert(action: QuickChatAction) { + val existingIndex = actionsFlow.value.indexOfFirst { it.uuid == action.uuid } + actionsFlow.value = + if (existingIndex >= 0) { + actionsFlow.value.toMutableList().also { it[existingIndex] = action } + } else { + actionsFlow.value + action + } + .sortedBy { it.position } + } + + override suspend fun deleteAll() { + actionsFlow.value = emptyList() + } + + override suspend fun delete(action: QuickChatAction) { + actionsFlow.value = + actionsFlow.value + .filterNot { it.uuid == action.uuid } + .map { if (it.position > action.position) it.copy(position = it.position - 1) else it } + } + + override suspend fun setItemPosition(uuid: Long, newPos: Int) { + actionsFlow.value = + actionsFlow.value.map { if (it.uuid == uuid) it.copy(position = newPos) else it }.sortedBy { it.position } + } + + /** Seeds the current list of actions (useful for test setup). */ + fun setActions(actions: List) { + actionsFlow.value = actions.sortedBy { it.position } + } + + /** Returns the current in-memory snapshot. */ + val currentActions: List + get() = actionsFlow.value +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt new file mode 100644 index 000000000..aa68e9b21 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.FileInfo +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig + +/** + * A test double for [RadioConfigRepository] backed by in-memory [kotlinx.coroutines.flow.MutableStateFlow]s. + * + * All mutator methods update the underlying state flows synchronously so tests can observe changes immediately. + * [deviceProfileFlow] is derived from [localConfigFlow], [moduleConfigFlow], and the current channel set. + */ +@Suppress("TooManyFunctions") +class FakeRadioConfigRepository : + BaseFake(), + RadioConfigRepository { + + private val channelSetBacking = mutableStateFlow(ChannelSet()) + override val channelSetFlow: Flow = channelSetBacking + + private val localConfigBacking = mutableStateFlow(LocalConfig()) + override val localConfigFlow: Flow = localConfigBacking + + private val moduleConfigBacking = mutableStateFlow(LocalModuleConfig()) + override val moduleConfigFlow: Flow = moduleConfigBacking + + private val deviceProfileBacking = mutableStateFlow(DeviceProfile()) + override val deviceProfileFlow: Flow = deviceProfileBacking + val currentDeviceProfile: DeviceProfile + get() = deviceProfileBacking.value + + private val deviceUIConfigBacking = mutableStateFlow(null) + override val deviceUIConfigFlow: Flow = deviceUIConfigBacking + + private val fileManifestBacking = mutableStateFlow>(emptyList()) + override val fileManifestFlow: Flow> = fileManifestBacking + + val currentChannelSet: ChannelSet + get() = channelSetBacking.value + + val currentLocalConfig: LocalConfig + get() = localConfigBacking.value + + val currentModuleConfig: LocalModuleConfig + get() = moduleConfigBacking.value + + val currentDeviceUIConfig: DeviceUIConfig? + get() = deviceUIConfigBacking.value + + val currentFileManifest: List + get() = fileManifestBacking.value + + /** + * Last [Config] passed to [setLocalConfig] (null until called). Tests should use [setLocalConfigDirect] to drive + * state. + */ + var lastSetLocalConfig: Config? = null + private set + + /** Last [ModuleConfig] passed to [setLocalModuleConfig] (null until called). */ + var lastSetModuleConfig: ModuleConfig? = null + private set + + init { + registerResetAction { + lastSetLocalConfig = null + lastSetModuleConfig = null + } + } + + override suspend fun clearChannelSet() { + channelSetBacking.value = ChannelSet() + } + + override suspend fun replaceAllSettings(settingsList: List) { + channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList) + } + + override suspend fun updateChannelSettings(channel: Channel) { + val current = channelSetBacking.value.settings.toMutableList() + while (current.size <= channel.index) current.add(ChannelSettings()) + current[channel.index] = channel.settings ?: ChannelSettings() + channelSetBacking.value = channelSetBacking.value.copy(settings = current) + } + + override suspend fun clearLocalConfig() { + localConfigBacking.value = LocalConfig() + } + + override suspend fun setLocalConfig(config: Config) { + lastSetLocalConfig = config + } + + override suspend fun clearLocalModuleConfig() { + moduleConfigBacking.value = LocalModuleConfig() + } + + override suspend fun setLocalModuleConfig(config: ModuleConfig) { + lastSetModuleConfig = config + } + + override suspend fun setDeviceUIConfig(config: DeviceUIConfig) { + deviceUIConfigBacking.value = config + } + + override suspend fun clearDeviceUIConfig() { + deviceUIConfigBacking.value = null + } + + override suspend fun addFileInfo(info: FileInfo) { + fileManifestBacking.value = fileManifestBacking.value + info + } + + override suspend fun clearFileManifest() { + fileManifestBacking.value = emptyList() + } + + /** Directly sets the [LocalConfig] without merging (preferred for test setup). */ + fun setLocalConfigDirect(config: LocalConfig) { + localConfigBacking.value = config + } + + /** Directly sets the [LocalModuleConfig] without merging (preferred for test setup). */ + fun setLocalModuleConfigDirect(config: LocalModuleConfig) { + moduleConfigBacking.value = config + } + + /** Directly sets the combined [DeviceProfile] emitted by [deviceProfileFlow]. */ + fun setDeviceProfile(profile: DeviceProfile) { + deviceProfileBacking.value = profile + } + + /** Directly sets the [ChannelSet] (bypasses [updateChannelSettings]/[replaceAllSettings]). */ + fun setChannelSet(channelSet: ChannelSet) { + channelSetBacking.value = channelSet + } +} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt new file mode 100644 index 000000000..a52b86bd0 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.meshtastic.core.repository.TracerouteSnapshotRepository +import org.meshtastic.proto.Position + +/** + * A test double for [TracerouteSnapshotRepository] keyed by `logUuid`. + * + * Use [upsertSnapshotPositions] as you would in production, or [seedSnapshot] to directly inject state for a log. + */ +class FakeTracerouteSnapshotRepository : + BaseFake(), + TracerouteSnapshotRepository { + + private val snapshots = mutableStateFlow>>(emptyMap()) + private val requestIds = mutableMapOf() + + init { + registerResetAction { requestIds.clear() } + } + + override fun getSnapshotPositions(logUuid: String): Flow> = + snapshots.map { it[logUuid].orEmpty() } + + override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) { + requestIds[logUuid] = requestId + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Directly seeds the snapshot for a log (bypasses request-id tracking). */ + fun seedSnapshot(logUuid: String, positions: Map) { + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Returns the last request-id recorded for [logUuid], or `null` if none. */ + fun lastRequestId(logUuid: String): Int? = requestIds[logUuid] +} diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt new file mode 100644 index 000000000..f9a63c712 --- /dev/null +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import app.cash.turbine.test +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RepositoryFakesTest { + + @Test + fun `FakeDeviceHardwareRepository returns seeded hardware and records calls`() = runTest { + val repo = FakeDeviceHardwareRepository() + val hw = DeviceHardware(hwModel = 42, hwModelSlug = "TEST", platformioTarget = "tlora") + repo.setHardware(hwModel = 42, target = "tlora", device = hw) + + val hit = repo.getDeviceHardwareByModel(hwModel = 42, target = "tlora", forceRefresh = false) + val miss = repo.getDeviceHardwareByModel(hwModel = 99) + + assertEquals(hw, hit.getOrNull()) + assertNull(miss.getOrNull()) + assertEquals(2, repo.recordedCalls.size) + assertEquals(Triple(42, "tlora", false), repo.recordedCalls.first()) + } + + @Test + fun `FakeFirmwareReleaseRepository emits stable and alpha releases`() = runTest { + val repo = FakeFirmwareReleaseRepository() + val stable = FirmwareRelease(id = "1.0", title = "1.0", pageUrl = "", zipUrl = "") + val alpha = FirmwareRelease(id = "1.1-a", title = "1.1-a", pageUrl = "", zipUrl = "") + + repo.setStableRelease(stable) + repo.setAlphaRelease(alpha) + + assertEquals(stable, repo.stableRelease.first()) + assertEquals(alpha, repo.alphaRelease.first()) + + repo.invalidateCache() + repo.invalidateCache() + assertEquals(2, repo.invalidateCacheCalls) + } + + @Test + fun `FakeQuickChatActionRepository upsert delete and reorder`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "hi", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "bye", position = 1) + + repo.upsert(a) + repo.upsert(b) + assertEquals(listOf(a, b), repo.getAllActions().first()) + + repo.setItemPosition(uuid = 1L, newPos = 5) + assertEquals(listOf(2L, 1L), repo.getAllActions().first().map { it.uuid }) + + repo.delete(b) + assertEquals(1, repo.currentActions.size) + + repo.deleteAll() + assertTrue(repo.currentActions.isEmpty()) + } + + @Test + fun `FakeQuickChatActionRepository delete compacts positions`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "", position = 1) + val c = QuickChatAction(uuid = 3L, name = "C", message = "", position = 2) + repo.upsert(a) + repo.upsert(b) + repo.upsert(c) + + repo.delete(b) + + // Matches real DAO's decrementPositionsAfter: positions must stay contiguous. + assertEquals(listOf(1L to 0, 3L to 1), repo.currentActions.map { it.uuid to it.position }) + } + + @Test + fun `FakeTracerouteSnapshotRepository roundtrips positions keyed by log uuid`() = runTest { + val repo = FakeTracerouteSnapshotRepository() + val positions = mapOf(1 to Position(latitude_i = 10), 2 to Position(latitude_i = 20)) + repo.upsertSnapshotPositions(logUuid = "log-1", requestId = 99, positions = positions) + + repo.getSnapshotPositions("log-1").test { assertEquals(positions, awaitItem()) } + assertEquals(99, repo.lastRequestId("log-1")) + assertNull(repo.lastRequestId("other")) + } + + @Test + fun `FakeRadioConfigRepository tracks channel set and module config`() = runTest { + val repo = FakeRadioConfigRepository() + val a = ChannelSettings(name = "A") + val b = ChannelSettings(name = "B") + + repo.replaceAllSettings(listOf(a, b)) + assertEquals(listOf(a, b), repo.currentChannelSet.settings) + + repo.updateChannelSettings(Channel(index = 1, settings = ChannelSettings(name = "B2"))) + assertEquals("B2", repo.currentChannelSet.settings[1].name) + + repo.clearChannelSet() + assertTrue(repo.currentChannelSet.settings.isEmpty()) + } +} From b979663e24702d306225f48016842bd312f631a1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:13:01 -0500 Subject: [PATCH 17/38] refactor: consolidate metric formatting through MetricFormatter (#5169) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/common/util/MetricFormatter.kt | 7 ++++ .../core/common/util/MetricFormatterTest.kt | 20 +++++++++++ .../node/metrics/EnvironmentMetrics.kt | 34 ++++++++++++++----- .../radio/component/LoadingOverlay.kt | 4 +-- .../component/PacketResponseStateDialog.kt | 4 +-- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt index 8e57b4dbb..51905ff41 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt @@ -23,6 +23,7 @@ package org.meshtastic.core.common.util * All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional * for a mesh networking app where consistency matters. */ +@Suppress("TooManyFunctions") object MetricFormatter { fun temperature(celsius: Float, isFahrenheit: Boolean): String { @@ -47,6 +48,12 @@ object MetricFormatter { fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB" fun rssi(value: Int): String = "$value dBm" + + fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String = + "${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s" + + fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String = + "${NumberFormatter.format(millimeters, decimalPlaces)} mm" } private const val FAHRENHEIT_SCALE = 1.8f diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt index b602a4a62..94781fca3 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt @@ -120,4 +120,24 @@ class MetricFormatterTest { fun snrNegative() { assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f)) } + + @Test + fun windSpeed() { + assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f)) + } + + @Test + fun windSpeedZero() { + assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f)) + } + + @Test + fun rainfall() { + assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f)) + } + + @Test + fun rainfallZero() { + assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f)) + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 77c6781f1..d09bdc8d1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType @@ -165,7 +166,10 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.HUMIDITY.color) Spacer(Modifier.width(4.dp)) Text( - text = formatString("%s %.2f%%", stringResource(Res.string.humidity), humidity), + text = + "${stringResource( + Res.string.humidity, + )} ${MetricFormatter.percent(humidity, decimalPlaces = 2)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -178,7 +182,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.BAROMETRIC_PRESSURE.color) Spacer(Modifier.width(4.dp)) Text( - text = formatString("%.2f hPa", pressure), + text = MetricFormatter.pressure(pressure, decimalPlaces = 2), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -286,7 +290,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasVoltage) { val voltage = envMetrics.voltage!! Text( - text = formatString("%s %.2f V", stringResource(Res.string.voltage), voltage), + text = "${stringResource(Res.string.voltage)} ${MetricFormatter.voltage(voltage)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -294,7 +298,10 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasCurrent) { val currentValue = envMetrics.current!! Text( - text = formatString("%s %.2f mA", stringResource(Res.string.current), currentValue), + text = + "${stringResource( + Res.string.current, + )} ${MetricFormatter.current(currentValue, decimalPlaces = 2)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -387,7 +394,11 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { envMetrics.wind_direction!!, ) } else { - formatString("%s %.1f m/s", stringResource(Res.string.wind_speed), envMetrics.wind_speed!!) + formatString( + "%s %s", + stringResource(Res.string.wind_speed), + MetricFormatter.windSpeed(envMetrics.wind_speed!!), + ) } Text( text = dirText, @@ -403,14 +414,14 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (hasGust) { Text( - text = formatString("%s %.1f m/s", stringResource(Res.string.wind_gust), envMetrics.wind_gust!!), + text = "${stringResource(Res.string.wind_gust)} ${MetricFormatter.windSpeed(envMetrics.wind_gust!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) } if (hasLull) { Text( - text = formatString("%s %.1f m/s", stringResource(Res.string.wind_lull), envMetrics.wind_lull!!), + text = "${stringResource(Res.string.wind_lull)} ${MetricFormatter.windSpeed(envMetrics.wind_lull!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -427,7 +438,10 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (has1h) { Text( - text = formatString("%s %.1f mm", stringResource(Res.string.rainfall_1h), envMetrics.rainfall_1h!!), + text = + "${stringResource( + Res.string.rainfall_1h, + )} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -435,7 +449,9 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) if (has24h) { Text( text = - formatString("%s %.1f mm", stringResource(Res.string.rainfall_24h), envMetrics.rainfall_24h!!), + "${stringResource( + Res.string.rainfall_24h, + )} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt index 8039dc37d..2646b20cb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.feature.settings.radio.ResponseState private const val LOADING_OVERLAY_ALPHA = 0.8f @@ -73,7 +73,7 @@ fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) { trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Text( - text = formatString("%.0f%%", progress * PERCENTAGE_FACTOR), + text = MetricFormatter.percent(progress * PERCENTAGE_FACTOR, decimalPlaces = 0), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt index 18d79e08f..c319c4f7f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.close @@ -111,7 +111,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) val progress by animateFloatAsState(targetValue = clampedProgress, label = "progress") Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = formatString("%.0f%%", progress * 100f), + text = MetricFormatter.percent(progress * 100f, decimalPlaces = 0), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.secondary, ) From 15a7c19b74c4b97875098975e3abab88a08022ab Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:13:26 -0500 Subject: [PATCH 18/38] chore(r8): remove redundant keep rules covered by consumer rules (#5172) Co-authored-by: GitHub Copilot CLI <223556219+Copilot@users.noreply.github.com> --- config/proguard/shared-rules.pro | 100 ++++++++++--------------------- core/model/consumer-rules.pro | 2 - core/proto/consumer-rules.pro | 43 ------------- desktop/proguard-rules.pro | 5 +- 4 files changed, 36 insertions(+), 114 deletions(-) delete mode 100644 core/model/consumer-rules.pro delete mode 100644 core/proto/consumer-rules.pro diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro index fada20be3..8d0d8efde 100644 --- a/config/proguard/shared-rules.pro +++ b/config/proguard/shared-rules.pro @@ -20,12 +20,10 @@ -keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations # ---- Kotlin / Coroutines ---------------------------------------------------- - --keep class kotlin.Metadata { *; } --keep class kotlin.reflect.** { *; } --keep class kotlin.coroutines.Continuation { *; } --keep class kotlinx.coroutines.** { *; } --dontwarn kotlinx.coroutines.** +# Kotlin stdlib and kotlinx-coroutines ship their own consumer ProGuard rules +# (kotlin-stdlib and kotlinx-coroutines-core consumer-rules.pro) which keep +# Metadata, Continuation, kotlin.reflect internals, and debug metadata. No +# explicit wildcards needed here. # ---- Koin DI (reflection-based injection) ----------------------------------- @@ -41,9 +39,7 @@ -keep @org.koin.core.annotation.ComponentScan class * { *; } -keep @org.koin.core.annotation.Single class * { *; } -keep @org.koin.core.annotation.Factory class * { *; } - -# Generated Koin module extensions (Koin Annotations plugin output) --keep class org.meshtastic.**.di.** { *; } +-keep @org.koin.core.annotation.KoinViewModel class * { *; } # ---- kotlinx-serialization -------------------------------------------------- @@ -63,13 +59,14 @@ # ---- Wire Protobuf ---------------------------------------------------------- -# Wire generates ADAPTER companion objects accessed via reflection --keep class com.squareup.wire.** { *; } --dontwarn com.squareup.wire.** - -# Generated proto message classes (both meshtastic protos and internal package) --keep class org.meshtastic.proto.** { *; } --keep class meshtastic.** { *; } +# Wire generates an ADAPTER static field on every Message subclass accessed +# reflectively during encoding/decoding. Keep those fields and the +# ProtoAdapter subclasses themselves; Wire's bundled consumer rules preserve +# the runtime itself. +-keepclassmembers class * extends com.squareup.wire.Message { + public static *** ADAPTER; +} +-keepclassmembers class * extends com.squareup.wire.ProtoAdapter { *; } # Suppress warnings about missing Android Parcelable (Wire cross-platform stubs # when compiling for non-Android JVM targets; harmless on Android). @@ -86,40 +83,24 @@ -keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } -keep class org.meshtastic.core.database.MeshtasticDatabase { *; } -# Room DAOs — Room generates implementations at compile time; keep interfaces --keep class org.meshtastic.core.database.dao.** { *; } - -# Room Entities — accessed via reflection for column mapping --keep class org.meshtastic.core.database.entity.** { *; } - -# Room TypeConverters — invoked reflectively --keep class org.meshtastic.core.database.Converters { *; } - -# Room generated _Impl classes --keep class **_Impl { *; } +# Room's own consumer rules (from androidx.room3) keep DAOs, entities, +# generated _Impl classes, and TypeConverters referenced from the database. # ---- SQLite bundled -------------------------------------------------------- - --keep class androidx.sqlite.** { *; } --dontwarn androidx.sqlite.** +# androidx.sqlite ships consumer rules. # ---- Ktor (ServiceLoader + plugin discovery) -------------------------------- --keep class io.ktor.** { *; } --dontwarn io.ktor.** - -# Keep ServiceLoader metadata files +# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory +# implementations reflectively via ServiceLoader). -keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } # ---- Coil 3 (image loading) ------------------------------------------------- - --keep class coil3.** { *; } --dontwarn coil3.** +# coil3 ships consumer rules. # ---- Kable BLE -------------------------------------------------------------- - --keep class com.juul.kable.** { *; } --dontwarn com.juul.kable.** +# com.juul.kable ships consumer rules; if release builds fail with missing +# Kable classes, restore a narrow keep for the specific reflection-loaded type. # ---- Compose Multiplatform resources ---------------------------------------- @@ -127,17 +108,14 @@ # Without these the fdroid flavor has crashed at startup with a misleading # URLDecodeException due to R8 exception-class merging. -keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.** { *; } +-keep class org.meshtastic.core.resources.Res { *; } +-keepclassmembers class org.meshtastic.core.resources.Res$* { *; } # ---- AboutLibraries --------------------------------------------------------- - --keep class com.mikepenz.aboutlibraries.** { *; } --dontwarn com.mikepenz.aboutlibraries.** +# com.mikepenz.aboutlibraries ships consumer rules. # ---- Multiplatform Markdown Renderer ---------------------------------------- - --keep class com.mikepenz.markdown.** { *; } --dontwarn com.mikepenz.markdown.** +# com.mikepenz.markdown ships consumer rules. # ---- QR Code Kotlin --------------------------------------------------------- @@ -147,36 +125,24 @@ -dontwarn qrcode.** # ---- Kermit logging --------------------------------------------------------- - --keep class co.touchlab.kermit.** { *; } --dontwarn co.touchlab.kermit.** +# co.touchlab.kermit ships consumer rules. # ---- Okio ------------------------------------------------------------------- - --keep class okio.** { *; } --dontwarn okio.** +# okio ships consumer rules. # ---- DataStore -------------------------------------------------------------- - --keep class androidx.datastore.** { *; } --dontwarn androidx.datastore.** +# androidx.datastore ships consumer rules. # ---- Paging ----------------------------------------------------------------- - --keep class androidx.paging.** { *; } --dontwarn androidx.paging.** +# androidx.paging ships consumer rules. # ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) ----------------- - --keep class androidx.lifecycle.** { *; } --keep class androidx.navigation3.** { *; } --dontwarn androidx.lifecycle.** --dontwarn androidx.navigation3.** +# androidx.lifecycle and androidx.navigation3 ship consumer rules. # ---- Meshtastic shared model ------------------------------------------------ - -# Core model classes (used in serialization, Room, and Koin injection) --keep class org.meshtastic.core.model.** { *; } +# core.model types are reached via static references from Koin-wired graphs, +# Room entities, and kotlinx-serialization @Serializable companions — all of +# which have their own keep rules above. # ---- Compose Runtime & Animation -------------------------------------------- diff --git a/core/model/consumer-rules.pro b/core/model/consumer-rules.pro deleted file mode 100644 index 5f75d687d..000000000 --- a/core/model/consumer-rules.pro +++ /dev/null @@ -1,2 +0,0 @@ --keep class org.meshtastic.core.model.DataPacket --keep class org.meshtastic.core.model.DataPacket$CREATOR diff --git a/core/proto/consumer-rules.pro b/core/proto/consumer-rules.pro deleted file mode 100644 index e9dc3751a..000000000 --- a/core/proto/consumer-rules.pro +++ /dev/null @@ -1,43 +0,0 @@ -# Core proto classes required for packet handling and serialization -# FromRadio and related message types (primary packet container) --keep class org.meshtastic.proto.FromRadio --keep class org.meshtastic.proto.Data --keep class org.meshtastic.proto.MeshPacket --keep class org.meshtastic.proto.LogRecord - -# Message type payloads (handled in packet routing) --keep class org.meshtastic.proto.AdminMessage --keep class org.meshtastic.proto.StoreAndForward --keep class org.meshtastic.proto.StoreForwardPlusPlus --keep class org.meshtastic.proto.Routing - -# User and Node information --keep class org.meshtastic.proto.User --keep class org.meshtastic.proto.NeighborInfo --keep class org.meshtastic.proto.Neighbor - -# Location and environment data --keep class org.meshtastic.proto.Position --keep class org.meshtastic.proto.Waypoint --keep class org.meshtastic.proto.StatusMessage - -# Telemetry data types --keep class org.meshtastic.proto.Telemetry --keep class org.meshtastic.proto.DeviceMetrics --keep class org.meshtastic.proto.EnvironmentMetrics --keep class org.meshtastic.proto.AirQualityMetrics --keep class org.meshtastic.proto.PowerMetrics --keep class org.meshtastic.proto.LocalStats --keep class org.meshtastic.proto.HostMetrics - -# Other data --keep class org.meshtastic.proto.Paxcount --keep class org.meshtastic.proto.DeviceMetadata - -# Configuration classes --keep class org.meshtastic.proto.ChannelSet --keep class org.meshtastic.proto.LocalConfig --keep class org.meshtastic.proto.Config --keep class org.meshtastic.proto.ModuleConfig --keep class org.meshtastic.proto.Channel --keep class org.meshtastic.proto.ClientNotification diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index 9e23e32c7..280214b2e 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -46,8 +46,9 @@ -keep class org.meshtastic.desktop.MainKt { *; } # ---- Ktor Java engine (desktop-only; Android uses OkHttp) ------------------- - --keep class io.ktor.client.engine.java.** { *; } +# io.ktor.client.engine.java ships consumer rules; the shared +# HttpClientEngineFactory ServiceLoader keep in shared-rules.pro covers the +# reflective discovery path. # ---- Meshtastic desktop host shell ------------------------------------------ From 56cbc3670ddec733ced6084cc2db2abaf40340dc Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:13:27 -0500 Subject: [PATCH 19/38] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5163) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-ar/strings.xml | 1 + .../composeResources/values-be/strings.xml | 2 ++ .../composeResources/values-bg/strings.xml | 10 ++++++++++ .../composeResources/values-ca/strings.xml | 1 + .../composeResources/values-cs/strings.xml | 2 ++ .../composeResources/values-de/strings.xml | 2 ++ .../composeResources/values-el/strings.xml | 1 + .../composeResources/values-es/strings.xml | 2 ++ .../composeResources/values-et/strings.xml | 5 +++++ .../composeResources/values-fi/strings.xml | 5 +++++ .../composeResources/values-fr/strings.xml | 2 ++ .../composeResources/values-ga/strings.xml | 1 + .../composeResources/values-gl/strings.xml | 1 + .../composeResources/values-he/strings.xml | 1 + .../composeResources/values-hr/strings.xml | 1 + .../composeResources/values-ht/strings.xml | 1 + .../composeResources/values-hu/strings.xml | 2 ++ .../composeResources/values-is/strings.xml | 1 + .../composeResources/values-it/strings.xml | 2 ++ .../composeResources/values-ja/strings.xml | 2 ++ .../composeResources/values-ko/strings.xml | 2 ++ .../composeResources/values-lt/strings.xml | 1 + .../composeResources/values-nl/strings.xml | 2 ++ .../composeResources/values-no/strings.xml | 1 + .../composeResources/values-pl/strings.xml | 2 ++ .../composeResources/values-pt-rBR/strings.xml | 2 ++ .../composeResources/values-pt/strings.xml | 2 ++ .../composeResources/values-ro/strings.xml | 2 ++ .../composeResources/values-ru/strings.xml | 2 ++ .../composeResources/values-sk/strings.xml | 2 ++ .../composeResources/values-sl/strings.xml | 1 + .../composeResources/values-sq/strings.xml | 1 + .../composeResources/values-sr/strings.xml | 2 ++ .../composeResources/values-srp/strings.xml | 2 ++ .../composeResources/values-sv/strings.xml | 2 ++ .../composeResources/values-tr/strings.xml | 2 ++ .../composeResources/values-uk/strings.xml | 2 ++ .../composeResources/values-zh-rCN/strings.xml | 2 ++ .../composeResources/values-zh-rTW/strings.xml | 15 +++++++++++++++ 39 files changed, 92 insertions(+) diff --git a/core/resources/src/commonMain/composeResources/values-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml index fc61e78d4..0aabb2f37 100644 --- a/core/resources/src/commonMain/composeResources/values-ar/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ar/strings.xml @@ -154,6 +154,7 @@ الرسائل إعدادات لورا الجهة + انقطع الاتصال استغرق وقت طويل المسافة الإعدادات diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml index 301ff3bb4..03216a10a 100644 --- a/core/resources/src/commonMain/composeResources/values-be/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml @@ -167,6 +167,8 @@ Граць LoRa Рэгіён + Адлучана + Злучаны Імя карыстальніка Пароль Уключана diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index fe1520458..0a1478bb2 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -201,10 +201,15 @@ Възстановяване на настройките по подразбиране Приложи Тема + Контраст Светла Тъмна По подразбиране на системата Избор на тема + Ниво на контраста + Стандартен + Среден + Висок Изпращане на местоположение в мрежата Компактно кодиране за Кирилица @@ -301,6 +306,8 @@ Батерия Използване на канала Използване на ефира + %1$s: %2$s%% + %1$s: %2$s V %1$s %1$s: %2$s записа @@ -483,6 +490,8 @@ Честотен слот Игнориране на MQTT Конфигуриране на MQTT + Прекъсната връзка + Свързано MQTT е активиран Адрес Потребителско име @@ -960,5 +969,6 @@ Въведете или изберете мрежа WiFi е конфигуриран успешно! Прилагането на конфигурацията за WiFi не е успешно + Изход Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-ca/strings.xml b/core/resources/src/commonMain/composeResources/values-ca/strings.xml index 485e1c9e1..d2ee2550b 100644 --- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml @@ -182,6 +182,7 @@ Sempre Traçar ruta Regió + Desconnectat Temps esgotat Distància Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index 6220218ac..66e9d84d8 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -525,6 +525,8 @@ Ignorovat MQTT OK do MQTT Nastavení MQTT + Odpojeno + Připojeno MQTT povoleno Adresa Uživatelské jméno diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 161feaa3e..4d1161573 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -613,6 +613,8 @@ MQTT ignorieren OK für MQTT MQTT Einstellungen + Verbindung getrennt + Verbunden MQTT aktiviert Adresse Benutzername diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml index 88feab55e..6d691ec4d 100644 --- a/core/resources/src/commonMain/composeResources/values-el/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml @@ -164,6 +164,7 @@ Μηνύματα LoRa Περιφέρεια + Αποσυνδεδεμένο Διεύθυνση Όνομα χρήστη Κωδικός πρόσβασης diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index 3470f7bed..d6505500f 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -492,6 +492,8 @@ Rango de Valores 0 - 500. Ignorar Paquetes MQTT Permitir MQTT Configuración MQTT + Desconectado + Conectado Activar el MQTT Dirección del Servidor MQTT Usuario diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 650c69122..9833e43a3 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -613,6 +613,8 @@ Keela MQTT Ok MQTTi MQTT sätted + Ühendus katkenud + Ühendatud MQTT lubatud Aadress Kasutajatunnus @@ -1208,5 +1210,8 @@ Sisestage või valige võrk WiFi edukalt seadistatud! WiFi sätete rakendamine ebaõnnestus + Meshtastic töölaud + Näita Meshtastic + Sule Kärgvõrgustik diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index 3d9e28e91..b7c861b5e 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -613,6 +613,8 @@ Ohita MQTT MQTT päällä MQTT asetukset + Ei yhdistetty + Yhdistetty MQTT käytössä Osoite Käyttäjänimi @@ -1209,5 +1211,8 @@ Syötä tai valitse verkko WiFi määritetty onnistuneesti! WiFi-asetusten käyttöönotto epäonnistui + Meshtastic työpöytä + Näytä Meshtastic + Lopeta Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index 0ef821cb6..f39208b94 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -554,6 +554,8 @@ Ignorer MQTT Transmission des paquets vers MQTT Configuration MQTT + Déconnecté + Connecté MQTT activé Adresse Nom d'utilisateur diff --git a/core/resources/src/commonMain/composeResources/values-ga/strings.xml b/core/resources/src/commonMain/composeResources/values-ga/strings.xml index a081daff2..7ddebc824 100644 --- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml @@ -213,6 +213,7 @@ Céimeanna i dtreo %1$d Céimeanna ar ais %2$d Réigiún + Na ceangailte Am tráth Sáth diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml index cc3c02597..0dc8fd892 100644 --- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml @@ -149,6 +149,7 @@ Sempre Traza-ruta Rexión + Desconectado Distancia diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml index 3afe39071..7239680f1 100644 --- a/core/resources/src/commonMain/composeResources/values-he/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml @@ -133,6 +133,7 @@ בדיקת מסלול הודעות אזור + מנותק מרחק הגדרות diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml index aae7d6690..6753d3559 100644 --- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml @@ -150,6 +150,7 @@ Detalji Crveno Regija + Odspojeno Udaljenost Meshtastic diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml index 7c4fc0f24..9c2b3beca 100644 --- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml @@ -201,6 +201,7 @@ Direk Hops vèsus %1$d Hops tounen %2$d Rejyon + Dekonekte Tan pase Distans diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml index f553a6a32..b0270a8ab 100644 --- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml @@ -515,6 +515,8 @@ MQTT figyelmen kívül hagyása MQTT-re továbbítható MQTT beállítások + Szétkapcsolva + Csatlakoztatva MQTT engedélyezve Cím Felhasználónév diff --git a/core/resources/src/commonMain/composeResources/values-is/strings.xml b/core/resources/src/commonMain/composeResources/values-is/strings.xml index 4e07e1c2a..ce8853250 100644 --- a/core/resources/src/commonMain/composeResources/values-is/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-is/strings.xml @@ -119,6 +119,7 @@ Hámarsksendingartíma náð. Ekki hægt að senda skilaboð, vinsamlegast reynið aftur síðar. Ferilkönnun Svæði + Aftengd diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index 744741047..903b098d7 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -560,6 +560,8 @@ Ignora MQTT OK per MQTT Configurazione MQTT + Disconnesso + Connesso MQTT abilitato Indirizzo Username diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml index 59b54d2f5..943ea2d90 100644 --- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml @@ -424,6 +424,8 @@ PAファン無効 MQTT を無視 MQTT設定 + 切断 + 接続済 MQTTを有効化 アドレス ユーザー名 diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml index 0a5bc4031..bc8f6bb3f 100644 --- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml @@ -373,6 +373,8 @@ PA fan 비활성화됨 MQTT로 부터 수신 무시 MQTT 설정 + 연결 끊김 + 연결됨 MQTT 활성화 서버 주소 사용자명 diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml index 9592d8b14..99f7fd3cf 100644 --- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml @@ -217,6 +217,7 @@ Skambučio simbolis! Raudona Regionas + Atsijungta Viešasis raktas Privatus raktas Baigėsi laikas diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml index ee07fb52b..4ef65fa1c 100644 --- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml @@ -312,6 +312,8 @@ Inkomende negeren Negeer MQTT MQTT Configuratie + Niet verbonden + Verbonden MQTT ingeschakeld Adres Gebruikersnaam diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml index 2ecd2a425..d539af4f1 100644 --- a/core/resources/src/commonMain/composeResources/values-no/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml @@ -222,6 +222,7 @@ Kopier Varsel, bjellekarakter! Region + Frakoblet Offentlig nøkkel Privat nøkkel Tidsavbrudd diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 272f515f8..32055e52a 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -499,6 +499,8 @@ Zignoruj MQTT Ok dla MQTT Konfiguracja MQTT + Rozłączono + Połączony Włącz MQTT Adres Nazwa użytkownika diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml index 521a84b48..dafb3b034 100644 --- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -382,6 +382,8 @@ Ventilador do PA desativado Ignorar MQTT Configurações MQTT + Desconectado + Conectado MQTT habilitado Endereço Nome de usuário diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml index 545bd4e6f..732a71dca 100644 --- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml @@ -366,6 +366,8 @@ Ignorar entrada Ignorar MQTT Configuração MQTT + Desconectado + Ligado MQTT ativo Endereço Utilizador diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index 984a939d8..aac8b4e16 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -599,6 +599,8 @@ Ignoră MQTT Acceptă MQTT Configurare MQTT + Deconectat + Conectat MQTT activat Adresă Nume de utilizator diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index dd0d4a53f..cf0b1d421 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -621,6 +621,8 @@ Игнорировать MQTT ОК в MQTT Настройка MQTT + Отключено + Подключено MQTT включен Адрес Имя пользователя diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml index e51ef506d..c6dd2ee2d 100644 --- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml @@ -362,6 +362,8 @@ LoRa Šírka pásma Región + Odpojené + Pripojený Adresa Používateľské meno Heslo diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml index 8025c4751..3605549aa 100644 --- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml @@ -226,6 +226,7 @@ Kopiraj Znak opozorilnega zvonca! Regija + Prekinjeno Javni ključ Zasebni ključ Časovna omejitev diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml index e70391f4d..ec38d179c 100644 --- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml @@ -202,6 +202,7 @@ Hops drejt %1$d Hops prapa %2$d 訊息 Rajon + I shkëputur Koha e skaduar Distanca diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index 21a9c14c1..7c1eff713 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -349,6 +349,8 @@ Игнориши MQTT Позитиван за MQTT MQTT подешавања + Raskačeno + Блутут повезан Адреса Корисничко име Лозинка diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index 516a963f4..9e8c0ce9b 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -349,6 +349,8 @@ Игнориши MQTT Позитиван за MQTT MQTT подешавања + Раскачено + Блутут повезан Адреса Корисничко име Лозинка diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index 27f368d7e..d970a394a 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -529,6 +529,8 @@ Ignorera MQTT Ok till MQTT MQTT-konfiguration + Frånkopplad + Ansluten MQTT är aktiverat Adress Användarnamn diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml index a3ae53c8c..5dd6adbfd 100644 --- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml @@ -366,6 +366,8 @@ PA fanı devre dışı MQTT'yi Yoksay MQTT Yapılandırması + Bağlantı kesildi + Bağlandı MQTT etkin Adres Kullanıcı adı diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index 3e96490dc..c99e8d45f 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -401,6 +401,8 @@ Перевизначити частоту Ігнорувати MQTT Налаштування MQTT + Відключено + Під’єднано MQTT увімкнений Адреса Ім'я користувача diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml index 87feeb9e2..b8a1a9f0c 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -568,6 +568,8 @@ 忽略 MQTT 使用MQTT MQTT设置 + 已断开连接 + 已连接 启用MQTT 地址 用户名 diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml index 354415089..950ffeba8 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -45,6 +45,7 @@ 無法識別 正在等待確認 發送佇列中 + 已傳送至 Mesh 不明 透過 SF++ 鏈路由… 已在 SF++ 鏈上確認 @@ -122,6 +123,7 @@ 嘗試獲取 GPS 位置的頻率(< 10 秒將保持 GPS 模組開啟)。 位置訊息可選的附加欄位。包含的欄位越多,訊息越大,將造成空中時間拉長和封包遺失的風險增加。 將盡可能使所有元件進入睡眠狀態。對於 tracker 和 sensor 角色,此模式將包含 LoRa 無線電。如果您想搭配手機應用程式使用設備,或正在使用沒有使用者按鈕的設備,請勿啟用此設定。 + 從您的私鑰生成並傳送給網狀網路中的其他節點,以供它們計算出共享密鑰。 用於與遠端設備交換密鑰。 被授權可對此節點發送管理訊息的公鑰。 設備處於受管理狀態,使用者無法變更任何設備設定。 @@ -594,6 +596,8 @@ 無視MQTT 允許轉發至 MQTT MQTT配置 + 已中斷連線 + 已連線 啟用MQTT服務器 地址 用戶名 @@ -853,6 +857,7 @@ BLE: %1$s WiFi: %1$s 無可用的 PAX 人流計量資料。 + mPWRD-OS 的 Wi-Fi 設定 藍牙裝置 連接裝置 超過速率限制,請稍後再嘗試。 @@ -1158,6 +1163,7 @@ 保留路由跳數 注意 裝置儲存空間與使用者介面(唯讀) + 主題 %1$s,語言 %2$s 未發現任何檔案。 連線 完成 @@ -1166,10 +1172,19 @@ 進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS 正在搜尋裝置… 找到裝置 + 搜尋網路 正在搜尋… + 正在套用 Wi-Fi 設定… 找不到網路 + 無法連接:%1$s + 無法搜尋到 Wi-Fi 網路:%1$s %1$d% 可用的網路 網路名稱(SSID) + 手動輸入或選擇一個網路 + Wi-Fi 已設定完成! + 無法套用 Wi-Fi 設定 + 顯示 Meshtastic + 離開 Meshtastic From dd74e501f302b5e178a1cdcc849b62b749877123 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:33:38 -0500 Subject: [PATCH 20/38] fix(ui): finish accessibility roles and action labels for clickable surfaces (#5170) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../composeResources/values/strings.xml | 8 +++ .../ui/components/DeviceListItem.kt | 18 +++++-- .../component/MessageActionsBottomSheet.kt | 50 ++++++++++++++++--- .../wifiprovision/ui/WifiProvisionScreen.kt | 9 +++- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 87268ecda..2c29ae3aa 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1276,4 +1276,12 @@ Filter Remove filter Show air quality legend + Show message status + Send reply + Copy message + Select message + Delete message + React with emoji + Select device + Select network diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index a4d8ecdd8..14f4dc42b 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -17,13 +17,13 @@ package org.meshtastic.feature.connections.ui.components import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -41,11 +41,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_select_device import org.meshtastic.core.resources.add import org.meshtastic.core.resources.bluetooth import org.meshtastic.core.resources.network @@ -108,11 +112,19 @@ fun DeviceListItem( is DeviceListEntry.Mock -> stringResource(Res.string.add) } + val selectLabel = stringResource(Res.string.action_select_device) + val isSelected = connectionState is ConnectionState.Connected val clickableModifier = if (onDelete != null) { - Modifier.combinedClickable(onClick = onSelect, onLongClick = onDelete) + Modifier.semantics { selected = isSelected } + .combinedClickable( + onClickLabel = selectLabel, + role = Role.RadioButton, + onClick = onSelect, + onLongClick = onDelete, + ) } else { - Modifier.clickable(onClick = onSelect) + Modifier.selectable(selected = isSelected, role = Role.RadioButton, onClick = onSelect) } ListItem( diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index c4c99720c..5ffb5ea1d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -36,11 +36,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_copy_message +import org.meshtastic.core.resources.action_delete_message +import org.meshtastic.core.resources.action_react_with_emoji +import org.meshtastic.core.resources.action_select_message +import org.meshtastic.core.resources.action_send_reply +import org.meshtastic.core.resources.action_show_message_status import org.meshtastic.core.resources.copy import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.device_metrics_label_value @@ -55,6 +62,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Reply import org.meshtastic.core.ui.icon.SelectAll +@Suppress("LongMethod") @Composable fun MessageActionsContent( quickEmojis: List, @@ -83,20 +91,35 @@ fun MessageActionsContent( Text(stringResource(Res.string.device_metrics_label_value, title, statusText.orEmpty())) }, leadingContent = { MessageStatusIcon(status = status) }, - modifier = Modifier.clickable(onClick = onStatus), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_show_message_status), + role = Role.Button, + onClick = onStatus, + ), ) } ListItem( headlineContent = { Text(stringResource(Res.string.reply)) }, leadingContent = { Icon(MeshtasticIcons.Reply, contentDescription = stringResource(Res.string.reply)) }, - modifier = Modifier.clickable(onClick = onReply), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_send_reply), + role = Role.Button, + onClick = onReply, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.copy)) }, leadingContent = { Icon(MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy)) }, - modifier = Modifier.clickable(onClick = onCopy), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_copy_message), + role = Role.Button, + onClick = onCopy, + ), ) ListItem( @@ -104,13 +127,23 @@ fun MessageActionsContent( leadingContent = { Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select)) }, - modifier = Modifier.clickable(onClick = onSelect), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_select_message), + role = Role.Button, + onClick = onSelect, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.delete)) }, leadingContent = { Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete)) }, - modifier = Modifier.clickable(onClick = onDelete), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_delete_message), + role = Role.Button, + onClick = onDelete, + ), ) } } @@ -130,7 +163,12 @@ private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit, Modifier.size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onReact(emoji) }, + .clickable( + onClickLabel = stringResource(Res.string.action_react_with_emoji), + role = Role.Button, + ) { + onReact(emoji) + }, contentAlignment = Alignment.Center, ) { Text(text = emoji, style = MaterialTheme.typography.titleMedium) diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt index 015a4e08b..397710fea 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -87,6 +88,7 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_select_network import org.meshtastic.core.resources.apply import org.meshtastic.core.resources.back import org.meshtastic.core.resources.cancel @@ -489,7 +491,12 @@ internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () - } }, colors = ListItemDefaults.colors(containerColor = containerColor), - modifier = Modifier.clickable(onClick = onClick), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_select_network), + role = Role.Button, + onClick = onClick, + ), ) } From 10bc58d4178fbd421dc2bac19b37bf0c89c4817e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:36:32 -0500 Subject: [PATCH 21/38] chore(strings): remove 4 unused string resources (#5173) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/commonMain/composeResources/values/strings.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 2c29ae3aa..481a94b78 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -408,12 +408,9 @@ User Info New node notifications SNR - Signal-to-Noise Ratio, a measure used in communications to quantify the level of a desired signal to the level of background noise. In Meshtastic and other wireless systems, a higher SNR indicates a clearer signal that can enhance the reliability and quality of data transmission. RSSI - Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection. (Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500. Device Metrics - Node Map Position Last position update Environment Metrics @@ -460,7 +457,6 @@ 1M Max Min - Avg Expand chart Collapse chart Unknown Age From c866f60b59fc2678c348b333a0f52ae2deb39831 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:36:33 -0500 Subject: [PATCH 22/38] diag(r8): disable minify for release builds (animation-freeze diagnostic) (#5174) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AndroidApplicationConventionPlugin.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 38cc021a7..81729f5a0 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -41,8 +41,22 @@ class AndroidApplicationConventionPlugin : Plugin { buildTypes { getByName("release") { - isMinifyEnabled = true - isShrinkResources = true + // DIAGNOSTIC BUILD (internal.60): R8 is fully disabled to prove + // whether the Compose animation freeze observed since CMP 1.11.0-beta02 + // is caused by R8 (shrinking / consumer-rule -assumenosideeffects / + // resource shrinking) or by a CMP runtime bug independent of R8. + // + // - If animations work in this build → R8 is the cause; follow up with + // a narrower diag (weaken Compose -keep rules to allow class-merging, + // or toggle isShrinkResources only). + // - If animations remain frozen → R8 is innocent; the bug is in + // compose-multiplatform 1.11.0-beta02 itself. File upstream and + // downgrade / pin. + // + // REVERT once the diagnostic is concluded — release builds MUST ship + // with R8 enabled. + isMinifyEnabled = false + isShrinkResources = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), rootProject.file("config/proguard/shared-rules.pro"), From a273dc6623a43033df4c39d769e4c286337cbd39 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:07:54 -0500 Subject: [PATCH 23/38] Revert "diag(r8): disable minify for release builds (animation-freeze diagnostic)" (#5176) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AndroidApplicationConventionPlugin.kt | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 81729f5a0..38cc021a7 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -41,22 +41,8 @@ class AndroidApplicationConventionPlugin : Plugin { buildTypes { getByName("release") { - // DIAGNOSTIC BUILD (internal.60): R8 is fully disabled to prove - // whether the Compose animation freeze observed since CMP 1.11.0-beta02 - // is caused by R8 (shrinking / consumer-rule -assumenosideeffects / - // resource shrinking) or by a CMP runtime bug independent of R8. - // - // - If animations work in this build → R8 is the cause; follow up with - // a narrower diag (weaken Compose -keep rules to allow class-merging, - // or toggle isShrinkResources only). - // - If animations remain frozen → R8 is innocent; the bug is in - // compose-multiplatform 1.11.0-beta02 itself. File upstream and - // downgrade / pin. - // - // REVERT once the diagnostic is concluded — release builds MUST ship - // with R8 enabled. - isMinifyEnabled = false - isShrinkResources = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), rootProject.file("config/proguard/shared-rules.pro"), From 61d7f6fef3e08811e9b0b54b34699ce6873b4ed6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:46:59 -0500 Subject: [PATCH 24/38] fix(deps): pin androidx-compose runtime-tracing/ui-test to CMP version (#5179) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gradle/libs.versions.toml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 12ab9480c..fe96dc45e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,10 +35,11 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" compose-multiplatform-material3 = "1.11.0-alpha06" -# AndroidX Compose test/tracing artifacts share a version track with CMP but are resolved -# independently by Maven. Pinning them to their own ref prevents Renovate from bumping the -# CMP plugin version when a new AndroidX Compose pre-release appears. -androidx-compose = "1.11.0-rc01" +# `androidx-compose-material` (M2) is independent of CMP and pinned separately +# because some third-party libs (maps-compose-widgets, datadog) drag in +# unversioned material transitives. Test/tracing artifacts in the +# androidx.compose.{runtime,ui} groups MUST track CMP — use compose-multiplatform +# as their version ref, not a separate pin. androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" @@ -121,8 +122,8 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) -androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose" } -androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose" } # Required by Robolectric Compose tests (registers ComponentActivity) +androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity) # Compose Multiplatform compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" } From ef0e159abbb504a671f90ac601f0d27d850a6823 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:20:58 -0500 Subject: [PATCH 25/38] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5177) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- .../composeResources/values-ar/strings.xml | 1 + .../composeResources/values-be/strings.xml | 1 + .../composeResources/values-bg/strings.xml | 6 +- .../composeResources/values-ca/strings.xml | 1 + .../composeResources/values-cs/strings.xml | 4 +- .../composeResources/values-de/strings.xml | 6 +- .../composeResources/values-el/strings.xml | 1 + .../composeResources/values-es/strings.xml | 4 +- .../composeResources/values-et/strings.xml | 8 +- .../composeResources/values-fi/strings.xml | 9 +- .../composeResources/values-fr/strings.xml | 182 +++++++++++++++++- .../composeResources/values-ga/strings.xml | 4 +- .../composeResources/values-gl/strings.xml | 1 + .../composeResources/values-he/strings.xml | 1 + .../composeResources/values-hr/strings.xml | 1 + .../composeResources/values-ht/strings.xml | 4 +- .../composeResources/values-hu/strings.xml | 4 +- .../composeResources/values-it/strings.xml | 4 +- .../composeResources/values-ja/strings.xml | 4 +- .../composeResources/values-ko/strings.xml | 4 +- .../composeResources/values-lt/strings.xml | 2 +- .../composeResources/values-nl/strings.xml | 4 +- .../composeResources/values-no/strings.xml | 4 +- .../composeResources/values-pl/strings.xml | 4 +- .../values-pt-rBR/strings.xml | 4 +- .../composeResources/values-pt/strings.xml | 4 +- .../composeResources/values-ro/strings.xml | 5 +- .../composeResources/values-ru/strings.xml | 6 +- .../composeResources/values-sk/strings.xml | 4 +- .../composeResources/values-sl/strings.xml | 4 +- .../composeResources/values-sq/strings.xml | 4 +- .../composeResources/values-sr/strings.xml | 4 +- .../composeResources/values-srp/strings.xml | 4 +- .../composeResources/values-sv/strings.xml | 5 +- .../composeResources/values-tr/strings.xml | 4 +- .../composeResources/values-uk/strings.xml | 4 +- .../values-zh-rCN/strings.xml | 5 +- .../values-zh-rTW/strings.xml | 6 +- .../android/fr-FR/changelogs/default.txt | 2 +- 39 files changed, 229 insertions(+), 100 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml index 0aabb2f37..2e4eaf53c 100644 --- a/core/resources/src/commonMain/composeResources/values-ar/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ar/strings.xml @@ -174,4 +174,5 @@ إعدادات بلوتوث + عربي diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml index 03216a10a..cb615de37 100644 --- a/core/resources/src/commonMain/composeResources/values-be/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml @@ -222,4 +222,5 @@ Сіні Зялёны Meshtastic + Фільтраваць diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index 0a1478bb2..cdf34f2d3 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -324,12 +324,9 @@ Публичният ключ не съвпада със записания ключ. Можете да премахнете възела и да го оставите да обмени ключове отново, но това може да показва по-сериозен проблем със сигурността. Свържете се с потребителя чрез друг надежден канал, за да определите дали промяната на ключа се дължи на фабрично нулиране или друго умишлено действие. Известия за нови възли SNR - Съотношение сигнал/шум, мярка, използвана в комуникациите за количествено определяне на нивото на желания сигнал спрямо нивото на фоновия шум. В Meshtastic и други безжични системи, по-високото съотношение сигнал/шум показва по-ясен сигнал, който може да подобри надеждността и качеството на предаване на данни. RSSI - Индикатор за силата на получения сигнал - измерване, използвано за определяне на нивото на получения сигнал, приемано от антената. По-високата стойност на RSSI обикновено показва по-силна и по-стабилна връзка. (Качество на въздуха в помещенията) относителна скала за IAQ, стойностите са измерени с Bosch BME680. Диапазон на стойностите 0–500. Метрики на устройството - Карта на възела Позиция Последна актуализация на позицията Показатели на околната среда @@ -369,7 +366,6 @@ Макс Мин - Ср Разгъване на диаграмата Свиване на диаграмата Неизвестна възраст @@ -971,4 +967,6 @@ Прилагането на конфигурацията за WiFi не е успешно Изход Meshtastic + Филтър + Изберете устройство diff --git a/core/resources/src/commonMain/composeResources/values-ca/strings.xml b/core/resources/src/commonMain/composeResources/values-ca/strings.xml index d2ee2550b..22b52e28e 100644 --- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml @@ -201,4 +201,5 @@ Meshtastic + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index 66e9d84d8..d3e0566ac 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -331,12 +331,9 @@ Informace o uživateli Oznámení o nových uzlech SNR - Poměr signálu k šumu (SNR) je veličina používaná k vyjádření poměru mezi úrovní požadovaného signálu a úrovní šumu na pozadí. V Meshtastic a dalších bezdrátových systémech vyšší hodnota SNR značí čistší signál, což může zvýšit spolehlivost a kvalitu přenosu dat. RSSI - Indikátor síly přijímaného signálu, měření, které se používá k určení hladiny výkonu přijímané anténou. Vyšší hodnota RSSI obvykle znamená silnější a stabilnější spojení. (Vnitřní kvalita ovzduší) relativní hodnota IAQ měřená Bosch BME680. Hodnota rozsahu 0–500. Metriky zařízení - Mapa uzlu Pozice Poslední aktualizace pozice Metriky prostředí @@ -971,4 +968,5 @@ Připojit Hotovo Meshtastic + Filtr diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 4d1161573..8e97b008f 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -383,12 +383,9 @@ Benutzerinfo Benachrichtigung neue Knoten SNR - Signal-Rausch-Verhältnis, ein in der Kommunikation verwendetes Maß, um den Pegel eines gewünschten Signals im Verhältnis zum Pegel des Hintergrundrauschens zu quantifizieren. Bei Meshtastic und anderen drahtlosen Systemen weist ein höheres SNR auf ein klareres Signal hin, das die Zuverlässigkeit und Qualität der Datenübertragung verbessern kann. RSSI - Indikator für die empfangene Signalstärke, eine Messung zur Bestimmung der von der Antenne empfangenen Leistungsstärke. Ein höherer RSSI-Wert weist im Allgemeinen auf eine stärkere und stabilere Verbindung hin. (Innenluftqualität) relativer IAQ-Wert gemessen von Bosch BME680. Gerätedaten - Standortkarte Knoten Standort Letzte Standortaktualisierung Umweltdaten @@ -435,7 +432,6 @@ 1 Monat Maximal Minimum - Durchschnitt Diagramm einblenden Diagramm ausblenden Alter unbekannt @@ -1211,4 +1207,6 @@ WLAN erfolgreich konfiguriert! WLAN Konfiguration konnte nicht angewendet werden Meshtastic + Filter + Gerät auswählen diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml index 6d691ec4d..8386ac2ea 100644 --- a/core/resources/src/commonMain/composeResources/values-el/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml @@ -201,4 +201,5 @@ Κόκκινο Μπλε Πράσινο + Φίλτρο diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index d6505500f..4c59aa547 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -300,13 +300,10 @@ Clave pública no coincide Notificaciones de nuevo nodo SNR - SNR: Ratio de señal a ruido, una medida utilizada en las comunicaciones para cuantificar el nivel de una señal deseada respecto al nivel del ruido de fondo. En Meshtastic y otros sistemas inalámbricos, un mayor SNR indica una señal más clara que puede mejorar la fiabilidad y la calidad de la transmisión de datos. RSSI - Indicador de Fuerza de Señal Recibida (RSSI en inglés), una medida utilizada para determinar el nivel de potencia que está siendo recibido por la antena. Un valor de RSSI más alto generalmente indica una conexión más fuerte y estable. (Calidad de Aire interior) escala relativa del valor IAQ como mediciones del sensor Bosch BME680. Rango de Valores 0 - 500. Métricas de Dispositivo - Mapa de Nodos Posición Última actualización Métricas de Entorno @@ -839,4 +836,5 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Conectar Hecho Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 9833e43a3..be6376d0c 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -383,12 +383,9 @@ Kasutaja teave Uue sõlme teade SNR - Signaali ja müra suhe (SNR) on mõõdik, mida kasutatakse soovitud signaali taseme ja taustamüra taseme vahelise suhte määramisel. Meshtastic ja teistes traadita süsteemides näitab kõrgem signaali ja müra suhe selgemat signaali, mis võib parandada andmeedastuse usaldusväärsust ja kvaliteeti. RSSI - Vastuvõetud signaali tugevuse indikaator (RSSI), mõõt mida kasutatakse antenni poolt vastuvõetava võimsustaseme määramiseks. Kõrgem RSSI väärtus näitab üldiselt tugevamat ja stabiilsemat ühendust. Siseõhu kvaliteet (IAQ) on suhtelise skaala väärtus, näiteks mõõtes Bosch BME680 abil. Väärtuste vahemik 0–500. Seadme mõõdikud - Sõlmede kaart Asukoht Viimase asukoha värskendus Keskkonnamõõdikud @@ -435,7 +432,6 @@ 1k Maksimaalselt Min - Keskm Laienda diagrammi Ahenda diagrammi Tundmatu vanus @@ -613,8 +609,11 @@ Keela MQTT Ok MQTTi MQTT sätted + Mitteaktiivne Ühendus katkenud + Ühendan… Ühendatud + Taas ühendan… MQTT lubatud Aadress Kasutajatunnus @@ -1214,4 +1213,5 @@ Näita Meshtastic Sule Kärgvõrgustik + Filtreeri diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index b7c861b5e..c3bc3dc9e 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -383,12 +383,9 @@ Käyttäjätiedot Uuden laitteen ilmoitukset SNR - Signaali-kohinasuhde (SNR) on mittari, jota käytetään viestinnässä halutun signaalin tason ja taustahälyn tason määrittämisessä. Meshtasticissa ja muissa langattomissa järjestelmissä korkeampi SNR tarkoittaa selkeämpää signaalia, joka voi parantaa tiedonsiirron luotettavuutta ja laatua. RSSI - Vastaanotetun signaalin voimakkuusindikaattori (RSSI) on mittari, jota käytetään määrittämään antennilla vastaanotetun signaalin voimakkuus. Korkeampi RSSI-arvo yleensä osoittaa vahvemman ja vakaamman yhteyden. Sisäilman laatu (IAQ) on suhteellinen asteikko, jota voidaan mitata mm. Bosch BME680 anturilla ja sen arvoväli on 0–500. Laitteen mittausloki - Laitekartta Sijainti Viimeisin sijainnin päivitys Ympäristöarvot @@ -435,7 +432,6 @@ 1 kk Kaikki Minimi - Keskiarvo Laajenna kaavio Pienennä kaavio Tuntematon ikä @@ -613,8 +609,11 @@ Ohita MQTT MQTT päällä MQTT asetukset + Passiivinen Ei yhdistetty + Yhdistetään… Yhdistetty + Yhdistetään uudelleen… MQTT käytössä Osoite Käyttäjänimi @@ -1215,4 +1214,6 @@ Näytä Meshtastic Lopeta Meshtastic + Suodatus + Valitse laite diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index f39208b94..b9c28e4cc 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -18,6 +18,7 @@ Meshtastic + Meshtastic %1$s Filtre Effacer le filtre de nœud Filtrer par @@ -40,9 +41,11 @@ Interne par Favoris Afficher uniquement les nœuds ignorés + Exclure MQTT Non reconnu En attente d'accusé de réception En file d'attente pour l'envoi + Délivré au nœud Inconnu Routage via chaîne SF++… Confirmé via chaîne SF++ @@ -119,7 +122,8 @@ Distance minimale en mètres pour considérer une diffusion de position intelligente. À quelle fréquence devrions-nous essayer d'obtenir une position GPS (<10sec le GPS est maintenu allumé). Champs optionnels à inclure dans les messages de position. Plus il y en a, plus le message est grand, plus cela augmentant le temps d'occupation du réseau et le risque de perte. - Sera en veille profonde autant que possible, pour les rôles traqueur et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. + Sera en veille profonde autant que possible, pour les rôles traceurs et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. + Généré à partir de votre clé publique et envoyé à d'autres nœuds sur le maillage pour leur permettre de calculer une clé secrète partagée. Utilisée pour créer une clé partagée avec un appareil distant. Clé publique autorisée à envoyer des messages d’administration à ce nœud. L'appareil est géré par un administrateur de maillage, l'utilisateur ne peut accéder à aucun des paramètres de l'appareil. @@ -163,18 +167,25 @@ Port : Connecté Connexions actuelles : - IP WiFi : + IP du Wifi : IP Ethernet : Connexion en cours Non connecté Aucun appareil sélectionné Périphérique inconnu + Aucun périphérique réseau trouvé + Pas de périphérique USB trouvé + USB + Mode Démo Connecté à la radio, mais en mode veille Mise à jour de l’application requise Vous devez mettre à jour cette application sur l'app store (ou Github). Il est trop vieux pour dialoguer avec le micrologiciel de la radio. Veuillez lire nos docs sur ce sujet. Aucun (désactivé) Notifications de service Remerciements + Bibliothèques Open Source + Meshtastic est construit avec les bibliothèques open source suivantes. Appuyez sur n'importe quelle bibliothèque pour voir sa licence. + %1$d Bibliothèques Cette URL de canal est invalide et ne peut pas être utilisée Panneau de débogage Contenu décodé : @@ -207,7 +218,21 @@ Correspondre à tout | N'importe quel Cela supprimera tous les paquets de journaux et les entrées de la base de données de votre appareil - c'est une réinitialisation complète, et est permanent. Effacer + Rechercher des émojis... + Plus d'actions Canal + %1$s: %2$s + Message de %1$s: %2$s + Entête + Élément %1$d + Pied de page + Exporter le paquet de données TAK + Point + Texte + Jauge + Dégradé + Ceci est un composable personnalisé + Avec plusieurs lignes et styles Statut d'envoi du message Nouveaux messages au-dessous Notifications de message @@ -228,10 +253,15 @@ Rétablir les valeurs par défaut Appliquer Thème + Contraste Clair Sombre Valeur par défaut du système Choisir un thème + Niveau de contraste + Standard + Milieu + Haut Fournir l'emplacement au maillage Encodage compact pour Cyrillique @@ -275,6 +305,7 @@ Message direct Reconfiguration de NodeDB Réception confirmée par le destinataire + Votre appareil peut se déconnecter et redémarrer lorsque les paramètres sont appliqués. Erreur Une erreur inconnue s'est produite Ignorer @@ -317,6 +348,8 @@ Actuellement : Toujours muet Non muet + Muet pour %1$d jours, %2$s heures + Muet pour %1$s heures Désactiver les notifications pour '%1$s' ? Réactiver les notifications pour '%1$s' ? Remplacer @@ -326,7 +359,10 @@ Batterie UtilCanal UtilAir + %1$s / %2$s%% + %1$s: %2$s V %1$s + %1$s: %2$s Temp Hum Temp sol @@ -347,12 +383,9 @@ Infos utilisateur Notifikasyon nouvo nœud SNR - Signal-to-Noise Ratio, une mesure utilisée dans les communications pour quantifier le niveau du signal par rapport au niveau du bruit de fond. Dans les systèmes Meshtastic et autres systèmes sans fil, un SNR plus élevé indique un signal plus clair qui peut améliorer la fiabilité et la qualité de la transmission de données. RSSI - Indicateur de force du signal reçu, une mesure utilisée pour déterminer le niveau de puissance reçu par l'antenne. Une valeur RSSI plus élevée indique généralement une connexion plus forte et plus stable. (Qualité de l'air intérieur) valeur de l'échelle relative IAQ mesurée par Bosch BME680. Plage de valeur 0–500. Métriques de l’appareil - Carte historique des positions Position Dernière mise à jour de position Métriques d'environnement @@ -381,13 +414,26 @@ Durée : %1$s s Route aller :\n\n Route retour :\n\n + Saut vers l'avant + Saut vers l'arrière + Aller/Retour Pas de réponse + Charge 1 m + Charge 5m + Charge 15 m + Moyenne de charge du système d'une minute + Moyenne de charge du système de cinq minutes + Moyenne de charge du système de 15 minutes + Mémoire système disponible en octets 1H 24H 1S 2S 1M Max + Min + Agrandir le graphique + Réduire le graphique Age inconnu Copier Caractère d'appel ! @@ -401,11 +447,17 @@ Canal 1 Canal 2 Canal 3 + Canal 4 + Canal 5 + Canal 6 + Canal 7 + Canal 8 Actif Tension Êtes-vous sûr ? Documentation du rôle de l'appareil et le billet de blog sur comment Choisir le rôle de l'appareil approprié.]]> Je sais ce que je fais. + La batterie du nœud %1$s est faible (%2$d%) Notifications de batterie faible Batterie faible : %1$s Notifications de batterie faible (nœuds favoris) @@ -531,6 +583,9 @@ Durée de sortie (en millisecondes) Durée de répétition de la sortie (secondes) Sonnerie + Sonnerie importée + Le fichier est vide + Erreur d'importation : %1$s Lancer Utiliser l'I2S comme buzzer LoRa @@ -554,8 +609,11 @@ Ignorer MQTT Transmission des paquets vers MQTT Configuration MQTT + Inactif Déconnecté + Connexion… Connecté + Reconnexion… MQTT activé Adresse Nom d'utilisateur @@ -627,6 +685,8 @@ Série activée Écho activé Vitesse de transmission série + RX + Tx Délai d'expiration Mode série Outrepasser le port série de la console @@ -661,8 +721,15 @@ Distance Lux Vent + Vitesse du vent + Rafales de vent + Vent à la traîne + Direction du vent + Pluie (1h) + Pluie (24h) Poids Radiation + Températeur 1-Wire Qualité de l'air intérieur (IAQ) URL @@ -679,6 +746,7 @@ Horodatage En-tête Vitesse + %1$d Km/h Sats Alt Fréq @@ -744,6 +812,11 @@ Afficher les points de repère Afficher les cercles de précision Notification client + Vérification de la clé + Requête de vérification de clé + Vérification de la clé terminée + Clé publique dupliquée détectée + Clé de chiffrement faible détectée Clés compromises détectées, sélectionnez OK pour régénérer. Régénérer la clé privée Êtes-vous sûr de vouloir régénérer votre clé privée ?\n\nLes nœuds qui peuvent avoir précédemment échangé des clés avec ce nœud devront supprimer ce nœud et ré-échanger des clés afin de reprendre une communication sécurisée. @@ -796,7 +869,14 @@ Composer un message Métriques de PAX PAX + PAX : %1$d + B:%1$d + W :%1$d + PAX : %1$s + BLE: %1$s + Wi-Fi : %1$s Aucune métrique PAX disponible. + Approvisionnement Wi-Fi pour mPWRD-OS Appareils Bluetooth Périphérique connecté Limite de débit dépassée. Veuillez réessayer plus tard. @@ -851,6 +931,8 @@ Terrain Hybride Gérer les calques de la carte + Les calques personnalisés prennent en charge les fichiers .kml, .kmz ou GeoJSON. + Aucun calque personnalisé chargé. Ajouter un calque Afficher le calque Supprimer le calque @@ -858,6 +940,10 @@ Nœuds à cet emplacement Type de carte sélectionné Gérer les sources de tuiles personnalisées + Ajouter un réseau de tuile personnalisée + Aucune source de tuiles personnalisées trouvée. + Modifier le réseau de tuile personnalisée + Supprimer le réseau de tuile personnalisée Le nom ne peut pas être vide. Le nom du fournisseur existe déjà. URL ne peut être vide. @@ -951,6 +1037,7 @@ Notes de Version Une erreur inconnue s'est produite Les informations de l'utilisateur du nœud sont manquantes. + Batterie trop faible (%1$d%). Veuillez charger votre appareil avant de mettre à jour. Impossible de récupérer le fichier firmware. Échec de la mise à jour USB Intégrité (hash) du firmware rejetée. Veuillez réessayer ou mettre à jours l'appareil via USB. @@ -1027,8 +1114,10 @@ Configuration Gérer à distance sans fil les paramètres et les canaux de votre appareil. Sélection du style de carte + Batterie : %1$d% Nœuds : %1$d en ligne / %2$d au total Temps de disponibilité : %1$s + ChUtil: %1$s% | AirTX: %2$s% Trafic : TX %1$d / RX %2$d (D: %3$d) Relais : %1$d (annulé: %2$d) Diagnostiques : %1$s @@ -1042,11 +1131,94 @@ Actualiser Mis à jour + Ajouter une couche de réseau + Fichier local MBTiles + Ajouter un fichier local MBTiles + TAK (ATAK) + Configuration TAK + Activer le serveur TAK local + Démarre un serveur TCP sur le port 8089 pour les connexions ATAK + Couleur de l'équipe + Rôle Membre + Non spécifié + Blanc + Jaune + Orange + Magenta Rouge + Marron + Pourpre + Bleu foncé Bleu + Cyan + Turquoise Vert + Vert Foncé + Marron + Non spécifié + Membre de l'équipe + Chef d'équipe + Quartier général + Tireur d'élite + Medic + Observateur de transfert + Opérateur de radio téléphonie + Doggo (K9) + Gestion du trafic + Configuration de la gestion du trafic Module activé + Déduplication de Position + Précision de position (octets) + Intervalle de position min (secs) + Réponse directe de NodeInfo + Max de saut pour une réponse directe + Limitation de débit + Fenêtre de limitation de taux (secs) + Paquets maximum dans la fenêtre + Ignorer les paquets inconnus + Seuil de paquets inconnu + Télémétrie locale uniquement (Relays) + Position locale uniquement (Relays) + Conserver les sauts du Routeur + Note + Stockage de l'appareil & UI (lecture seule) + Thème %1$s, Langue %2$s + Fichiers disponibles (%1$d ) : + - %1$s (%2$d octets) + Aucun fichier affiché. Connecter Terminé + Approvisionnement Wi-Fi pour mPWRD-OS + Fournissez les identifiants Wi-Fi à votre appareil mPWRD-OS via Bluetooth. + En savoir plus sur le projet mPWRD-OS\nhttps://github.com/mPWRD-OS + Recherche de l'appareil + Appareil détecté + Prêt à rechercher des réseaux WiFi. + Rechercher des réseaux + Recherche… + Application de la configuration WiFi… + Aucun réseau trouvé + Impossible de se connecter : %1$s + Échec de la recherche des réseaux WiFi : %1$s + %1$d% + Réseaux disponibles + Nom du réseau (SSID) + Saisir ou sélectionnez un réseau + WiFi configuré avec succès ! + Impossible d'appliquer la configuration WiFi + Meshtastic application de bureau + Afficher Meshtastic + Quitter Meshtastic + Exporter le paquet de données TAK + Filtre + Supprimer le filtre + Afficher le statut du message + Envoyer une réponse + Copier le message + Sélectionner le message + Supprimer le message + Réagir avec un emoji + Sélectionner l'appareil + Sélectionner le réseau diff --git a/core/resources/src/commonMain/composeResources/values-ga/strings.xml b/core/resources/src/commonMain/composeResources/values-ga/strings.xml index 7ddebc824..baabf41d0 100644 --- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml @@ -190,10 +190,7 @@ Cóid Poiblí Eochair Mícomhoiriúnacht na heochrach phoiblí Fógartha faoi na nodes nua - Ráta Sigineal go Torann, tomhas a úsáidtear i gcomhfhreagras chun an leibhéal de shígnéil inmhianaithe agus torann cúlra a mheas. I Meshtastic agus i gcórais gan sreang eile, ciallaíonn SNR níos airde go bhfuil sígneál níos soiléire ann agus ábalta méadú ar chreideamh agus cáilíocht an tarchur sonraí. - Táscaire Cumhachta Athnuachana Aithint an Aoise, tomhas a úsáidtear chun leibhéal cumhachta atá faighte ag an antsnáithe a mheas. Léiríonn RSSI níos airde gnóthachtáil níos laige atá i gceangal seasmhach agus níos láidre. (Cáilíocht Aeir Inmheánach) scála ábhartha den luach QAÍ a thomhas ag Bosch BME680. Scála Luach 0–500. - Léarscáil an Node Rialachas Rialú iargúlta Go dona @@ -230,4 +227,5 @@ + Scagaire diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml index 0dc8fd892..dc751d2e9 100644 --- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml @@ -165,4 +165,5 @@ + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml index 7239680f1..502d64056 100644 --- a/core/resources/src/commonMain/composeResources/values-he/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml @@ -148,4 +148,5 @@ + פילטר diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml index 6753d3559..114c3ed9a 100644 --- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml @@ -169,4 +169,5 @@ Crveno Meshtastic + Filtriraj diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml index 9c2b3beca..60e00d491 100644 --- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml @@ -186,10 +186,7 @@ Chifreman Kle Piblik Pa matche kle piblik Notifikasyon nouvo nœud - Rapò Siynal sou Bri, yon mezi ki itilize nan kominikasyon pou mezire nivo siynal vle a kont nivo bri ki nan anviwònman an. Nan Meshtastic ak lòt sistèm san fil, yon SNR pi wo endike yon siynal pi klè ki ka amelyore fyab ak kalite transmisyon done. - Endikatè Fòs Siynal Resevwa, yon mezi ki itilize pou detèmine nivo pouvwa siynal ki resevwa pa antèn nan. Yon RSSI pi wo jeneralman endike yon koneksyon pi fò ak plis estab. (Kalite Lèy Entèryè) echèl relatif valè IAQ jan li mezire pa Bosch BME680. Ranje valè 0–500. - Kat Nœud Administrasyon Administrasyon Remote Move @@ -218,4 +215,5 @@ + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml index b0270a8ab..33b795a7f 100644 --- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml @@ -316,12 +316,9 @@ Publikus kulcs nem egyezik Új állomás értesítések SNR - Jel–zaj arány (SNR): a kommunikációban a kívánt jel szintjének és a háttérzaj szintjének aránya. A Meshtastic és más vezeték nélküli rendszerek esetében a magasabb SNR tisztább jelet jelent, ami javítja az adatátvitel megbízhatóságát és minőségét. RSSI - Vett jelerősség-mutató (RSSI): az antenna által vett jel teljesítményszintjének mérése. A magasabb RSSI általában erősebb, stabilabb kapcsolatot jelez. (Beltéri levegőminőség) relatív IAQ érték a Bosch BME680 szenzor alapján. Értéktartomány: 0–500. Eszközmetrikák - Állomás Térkép Pozíció Utolsó pozíciófrissítés Környezeti metrikák @@ -853,4 +850,5 @@ Zöld Csatlakozás Meshtastic + Filter diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index 903b098d7..baa0e0947 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -355,12 +355,9 @@ Informazioni Utente Notifiche di nuovi nodi SNR - Rapporto segnale-rumore (Signal-to-Noise Ratio), una misura utilizzata nelle comunicazioni per quantificare il livello di un segnale desiderato rispetto al livello di rumore di fondo. In Meshtastic e in altri sistemi wireless, un SNR più elevato indica un segnale più chiaro che può migliorare l'affidabilità e la qualità della trasmissione dei dati. RSSI - Indicatore di forza del segnale ricevuto (Received Signal Strength Indicator), una misura utilizzata per determinare il livello di potenza ricevuto dall'antenna. Un valore RSSI più elevato indica generalmente una connessione più forte e più stabile. (Qualità dell'aria interna) scala relativa del valore della qualità dell'aria indoor, misurato da Bosch BME680. Valore Intervallo 0–500. Metriche Dispositivo - Mappa Dei Nodi Posizione Aggiornamento ultima posizione Metriche Ambientali @@ -963,4 +960,5 @@ Connetti Fatto Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml index 943ea2d90..64aa0fe05 100644 --- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml @@ -275,11 +275,8 @@ 公開キーが一致しません 新しいノードの通知 SN比 - 信号対ノイズ比(SN比)は、通信において、目的の信号のレベルを背景ノイズのレベルに対して定量化するために使用される尺度です。Meshtasticや他の無線システムでは、SN比が高いほど信号が鮮明であることを示し、データ伝送の信頼性と品質を向上させることができます。 RSSI - 受信信号強度インジケーター(RSSI)は、アンテナで受信している電力レベルを測定するための指標です。一般的にRSSI値が高いほど、より強力で安定した接続を示します。 (屋内空気品質) 相対スケールIAQ値は、ボッシュBME680によって測定されます。 値の範囲は 0-500。 - ノードマップ 位置 管理 リモート管理 @@ -655,4 +652,5 @@ モジュール有効 接続 Meshtastic + 絞り込み diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml index bc8f6bb3f..914446a60 100644 --- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml @@ -213,11 +213,8 @@ 공개 키가 일치하지 않습니다 새로운 노드 알림 SNR - 통신에서 원하는 신호의 수준을 배경 잡음의 수준과 비교하여 정량화하는 데 사용되는 신호 대 잡음비 Signal-to-Noise Ratio, SNR는 Meshtastic와 같은 무선 시스템에서 SNR이 높을수록 더 선명한 신호를 나타내어 데이터 전송의 안정성과 품질을 향상시킬 수 있습니다. RSSI - 수신 신호 강도 지표 Received Signal Strength Indicator, RSSI는 안테나가 수신하는 신호의 전력 수준을 측정하는 데 사용되는 지표입니다. RSSI 값이 높을수록 일반적으로 더 강력하고 안정적인 연결을 나타냅니다. (실내공기질) Bosch BME680으로 측정한 상대적 척도 IAQ 값. 범위 0–500. - 노드 지도 위치 최근 위치 업데이트 관리 @@ -542,4 +539,5 @@ 초록 연결 Meshtastic + 필터 diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml index 99f7fd3cf..33f5e4d59 100644 --- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml @@ -190,7 +190,6 @@ Naujo įtaiso pranešimas SNR RSSI - Įtaisų žemėlapis Administravimas Nuotolinis administravimas Silpnas @@ -238,4 +237,5 @@ Raudona + Filtras diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml index 4ef65fa1c..b6972b6ec 100644 --- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml @@ -201,11 +201,8 @@ Publieke sleutel komt niet overeen Nieuwe node meldingen SNR - Signal-to-Noise Ratio, een meeting die wordt gebruikt in de communicatie om het niveau van een gewenst signaal tegenover achtergrondlawaai te kwantificeren. In Meshtastische en andere draadloze systemen geeft een hoger SNR een zuiverder signaal aan dat de betrouwbaarheid en kwaliteit van de gegevensoverdracht kan verbeteren. RSSI - Ontvangen Signal Sterkte Indicator, een meting gebruikt om het stroomniveau te bepalen dat de antenne ontvangt. Een hogere RSSI-waarde geeft een sterkere en stabielere verbinding aan. (Binnenluchtkwaliteit) relatieve schaal IAQ waarde gemeten door Bosch BME680. Waarde tussen 0 en 500. - Node Kaart Positie Beheer Extern beheer @@ -418,4 +415,5 @@ Blauw Groen Verbinding maken + Filter diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml index d539af4f1..cd00c43e2 100644 --- a/core/resources/src/commonMain/composeResources/values-no/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml @@ -194,11 +194,8 @@ Direktemeldinger bruker den nye offentlige nøkkelinfrastrukturen for kryptering. Krever firmware versjon 2.5 eller høyere. Varsel om nye noder SNR - Signal-to-Noise Ratio, et mål som brukes i kommunikasjon for å sette nivået av et ønsket signal til bakgrunnstrøynivået. I Meshtastic og andre trådløse systemer tyder et høyere SNR på et klarere signal som kan forbedre påliteligheten og kvaliteten på dataoverføringen. RSSI - \"Received Signal Strength Indicator\", en måling som brukes til å bestemme strømnivået som mottas av antennen. Høyere RSSI verdi indikerer generelt en sterkere og mer stabil forbindelse. (Innendørs luftkvalitet) relativ skala IAQ-verdi målt ved Bosch BME680. Verdi 0–500. - Nodekart Administrasjon Fjernadministrasjon Dårlig @@ -241,4 +238,5 @@ + Filter diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 32055e52a..7c9b3433b 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -333,12 +333,9 @@ Informacje o użytkowniku Powiadomienia o nowych węzłach SNR: - Współczynnik sygnału do szumu (Signal-to-Noise Ratio) - miara stosowana w komunikacji do określania poziomu pożądanego sygnału w stosunku do poziomu szumu tła. W Meshtastic i innych systemach bezprzewodowych wyższy współczynnik SNR oznacza czystszy sygnał, który może zwiększyć niezawodność i jakość transmisji danych. RSSI: - Received Signal Strength Indicator - miara używana do określenia poziomu mocy odbieranej przez antenę. Wyższa wartość RSSI zazwyczaj oznacza silniejsze i bardziej stabilne połączenie. Jakość powietrza w pomieszczeniach (Indoor Air Quality) - wartość względna w skali IAQ mierzona czujnikiem BME680. Zakres wartości: 0–500. Metryka urządzenia - Ślad na mapie Pozycjonowanie Ostatnia aktualizacja lokalizacji Metryki środowiskowe @@ -752,4 +749,5 @@ Połącz Wykonano Meshtastic + Filtr diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml index dafb3b034..ac97b091c 100644 --- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -230,11 +230,8 @@ Chave pública não confere Novas notificações de nó SNR - Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado para o nível de ruído de fundo. Na Meshtastic e outros sistemas sem fios, uma SNR maior indica um sinal mais claro que pode melhorar a confiabilidade e a qualidade da transmissão de dados. RSSI - Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de potência que está sendo recebida pela antena. Um valor maior de RSSI geralmente indica uma conexão mais forte e mais estável. (Qualidade do ar interior) valor relativo da escala IAQ medido pelo Bosch BME680. Intervalo de Valor de 0–500. - Mapa do nó Posição Atualização da última posição Administração @@ -668,4 +665,5 @@ Verde Concluído Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml index 732a71dca..a00bce554 100644 --- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml @@ -219,11 +219,8 @@ Incompatibilidade de chave pública Notificações de novos nodes SNR - Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado com o nível de ruído de fundo. Em Meshtastic e outros sistemas sem fio. Quanto mais alta for a relação sinal-ruído, menor é o efeito do ruído de fundo sobre a deteção ou medição do sinal. RSSI - Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de energia que está a ser recebido pela antena. Um valor mais elevado de RSSI geralmente indica uma conexão mais forte e mais estável. (Qualidade do ar interior) valor relativo da escala IAQ conforme medida por Bosch BME680. Entre 0–500. - Mapa de nodes Posição Administração Administração Remota @@ -518,4 +515,5 @@ Verde Ligar Nome do nó de alternativo + Filtrar diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index aac8b4e16..f9787ba93 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -376,12 +376,9 @@ Info utilizator Notificări noduri noi SNR - Raportul semnal-zgomot (Signal-to-Noise Ratio), o măsură utilizată în comunicații pentru a cuantifica nivelul unui semnal dorit în raport cu nivelul zgomotului de fond. În Meshtastic și în alte sisteme wireless, un SNR mai mare indică un semnal mai clar, care poate îmbunătăți fiabilitatea și calitatea transmiterii datelor. RSSI - Indicatorul intensității semnalului recepționat (Received Signal Strength Indicator), o măsurătoare utilizată pentru a determina nivelul de putere recepționat de antenă. O valoare RSSI mai mare indică, în general, o conexiune mai puternică și mai stabilă. (Calitatea aerului interior) valoarea IAQ pe o scară relativă, măsurată cu Bosch BME680. Intervalul valorilor: 0–500. Valori dispozitiv - Harta nodurilor Poziție Ultima actualizare a poziției Indicatori de mediu @@ -424,7 +421,6 @@ 1W 2W Maxim - Medie Extindeți graficul Restrânge graficul Vârstă necunoscută @@ -1171,4 +1167,5 @@ WiFi configurat cu succes! Nu s-a reușit aplicarea configurației Wi-Fi Meshtastic + Filtru diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index cf0b1d421..cabfcd63f 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -389,12 +389,9 @@ Пользовательская информация Уведомления о новых нодах Сигнал/шум - Соотношение сигнал/шум, мера, используемая в коммуникациях для количественной оценки уровня желаемого сигнала по отношению к уровню фонового шума. В Meshtastic и других беспроводных системах более высокий SNR указывает на более четкий сигнал, который может повысить надежность и качество передачи данных. RSSI - Индикатор уровня принимаемого сигнала, измерение, используемое для определения уровня мощности, принимаемой антенной. Более высокое значение RSSI обычно указывает на более сильное и стабильное соединение. (Качество воздуха в помещении) Относительная шкала IAQ, измеренная Bosch BME680. Диапазон значений 0–500. Интервал передачи - Карта нод Местоположение Обновление последнего местоположения Метрики окружения @@ -443,7 +440,6 @@ Макс Мин - Сред Развернуть диаграмму Свернуть диаграмму Неизвестный возраст @@ -1227,4 +1223,6 @@ Wi-Fi успешно настроен! Не удалось применить настройку Wi-Fi Meshtastic + Фильтр + Выберите устройство diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml index c6dd2ee2d..6beec1a74 100644 --- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml @@ -257,11 +257,8 @@ Nezhoda verejného kľúča Notifikácie nových uzlov SNR - Pomer signálu od šumu (SNR), miera používaná v komunikácii na kvantifikáciu úrovne požadovaného signálu k úrovni hluku pozadia. V Meshtastic a iných bezdrôtových systémoch znamená vyšší SNR jasnejší signál, ktorý môže zvýšiť spoľahlivosť a kvalitu prenosu údajov. RSSI - Indikátor sily prijímaného signálu (RSSI), meranie používané na určenie úrovne výkonu prijatého skrz anténu. Vyššia hodnota RSSI vo všeobecnosti znamená silnejšie a stabilnejšie pripojenie. (Kvalita vzduchu v interiéri) relatívna hodnota IAQ meraná prístrojom Bosch BME680. Rozsah hodnôt 0–500. - Mapa uzlov Pozícia Administrácia Administrácia na diaľku @@ -430,4 +427,5 @@ Modrá Zelená Meshtastic + Filter diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml index 3605549aa..bff8e6150 100644 --- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml @@ -196,11 +196,8 @@ Neujemanje javnega ključa Obvestila novih vozlišč SNR - Razmerje med signalom in šumom je merilo, ki se uporablja v komunikacijah za količinsko opredelitev ravni želenega signala glede na raven hrupa v ozadju. V Meshtastic in drugih brezžičnih sistemih višji SNR pomeni jasnejši signal, ki lahko poveča zanesljivost in kakovost prenosa podatkov. RSSI - Indikator moči sprejetega signala je meritev, ki se uporablja za določanje ravni moči, ki jo sprejema antena. Višja vrednost RSSI na splošno pomeni močnejšo in stabilnejšo povezavo. (Kakovost zraka v zaprtih prostorih) relativna vrednost IAQ na lestvici, izmerjena z Bosch BME680. Razpon vrednosti 0–500. - Zemljevid vozlišč Administracija Administracija na daljavo Slab @@ -245,4 +242,5 @@ + Filter diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml index ec38d179c..edfac59b0 100644 --- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml @@ -186,10 +186,7 @@ Kriptimi me Çelës Publik Përputhje e Gabuar e Çelësit Publik Njoftimet për nyje të reja - Raporti i Sinjalit në Zhurmë, një masë e përdorur në komunikime për të kuantifikuar nivelin e një sinjali të dëshiruar ndaj nivelit të zhurmës në background. Në Meshtastic dhe sisteme të tjera pa tel, një SNR më i lartë tregon një sinjal më të pastër që mund të rrisë besueshmërinë dhe cilësinë e transmetimit të të dhënave. - Indikatori i Fuqisë së Sinjalit të Marrë, një matje e përdorur për të përcaktuar nivelin e energjisë që po merret nga antena. Një vlerë më e lartë RSSI zakonisht tregon një lidhje më të fortë dhe më të qëndrueshme. (Cilësia e Ajrit të Brendshëm) shkalla relative e vlerës IAQ siç matet nga Bosch BME680. Intervali i Vlerave 0–500. - Harta e Nyjës Administratë Administratë e Largët I Keq @@ -219,4 +216,5 @@ + Filtrimi diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index 7c1eff713..a365fc888 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -241,12 +241,9 @@ Неусаглашеност јавних кључева Обавештење о новом чвору SNR - Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података. RSSI - Indikator jačine primljenog signala RSSI, merenje koje se koristi za određivanje nivoa snage koji antena prima. Viša vrednost RSSI generalno ukazuje na jaču i stabilniju vezu. (Kvalitet vazduha u zatvorenom prostoru) relativna skala vrednosti IAQ merena Bosch BME680. Raspon vrednosti 0–500. Метрика уређаја - Mapa čvorova Позиција Метрике сензора Administracija @@ -432,4 +429,5 @@ Блутут Напајано + Filter diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index 9e8c0ce9b..5bfbb0a84 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -241,12 +241,9 @@ Неусаглашеност јавних кључева Обавештења о новим чворовима SNR - Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података. RSSI - Индикатор јачине примљеног сигнала RSSI је мера која се користи за одређивање нивоа снаге која се прима преко антене. Виша вредност RSSI генерално указује на јачу и стабилнију везу. Индекс квалитета ваздуха (IAQ) као мера за одређивање квалитета ваздуха унутрашњости, мерен са Bosch BME680. Вредности се крећу у распону од 0 до 500. Метрика уређаја - Мапа чворова Позиција Метрике сензора Администрација @@ -432,4 +429,5 @@ Блутут Напајано + Филтер diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index d970a394a..7d81b8a8c 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -339,12 +339,9 @@ Användarinfo Ny nod avisering SNR - Signal-to-Noise Ratio, är ett mått som används inom kommunikation för att kvantifiera nivån av en önskad signal mot nivån av bakgrundsbrus. I Meshtastic och andra trådlösa system indikerar en högre SNR en tydligare signal som kan förbättra tillförlitligheten och kvaliteten på dataöverföringen. RSSI - Received Signal Strength Indicator, ett mått som används för att avgöra effektnivån som togs emot av antennen. Ett högre RSSI-värde indikerar generellt en starkare och stabilare anslutning. (Indoor Air Quality) relativ skala IAQ värdet mätt med Bosch BME600. Värdeintervall 0-500. Enhetens mätvärden - Nod karta Plats Senaste positionsuppdatering Miljövärden @@ -948,4 +945,6 @@ Anslut Klart Meshtastic + Filter + Välj enhet diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml index 5dd6adbfd..75a9e3a5d 100644 --- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml @@ -213,11 +213,8 @@ Genel Anahtar Uyuşmazlığı Yeni düğüm bildirimleri SNR - Sinyal-Gürültü Oranı, iletişimde istenen bir sinyalin seviyesini arka plan gürültüsü seviyesine mukayese ölçmek için kullanılan bir ölçüdür. Meshtastic ve diğer kablosuz sistemlerde, daha yüksek bir SNR, veri iletiminin güvenilirliğini ve kalitesini artırabilecek daha net bir sinyale işaret eder. RSSI - Alınan Sinyal Gücü Göstergesi, anten tarafından alınan güç seviyesini belirlemek için kullanılan bir ölçüdür. Daha yüksek bir RSSI değeri genellikle daha güçlü ve daha istikrarlı bir bağlantıya işaret eder. (İç Hava Kalitesi) Bosch BME680 tarafından ölçülen bağıl ölçekli IAQ değeri. Değer Aralığı 0–500. - Düğüm Haritası Konum Yönetim Uzaktan Yönetim @@ -549,4 +546,5 @@ Yeşil Bağlan Meshtastic + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index c99e8d45f..b2034aae1 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -277,9 +277,7 @@ Сповіщення про нові вузли SNR RSSI - Показник рівня потужності сигналу — вимірювання, що використовується для визначення рівня потужності, що приймається антеною. Вище значення RSSI зазвичай вказує на міцніше та стабільніше з'єднання. Показники пристрою - Мапа вузлів Місцезнаходження Показники довкілля Адміністрування @@ -724,4 +722,6 @@ Під’єднатися Готово Meshtastic + Фільтри + Оберіть пристрій diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml index b8a1a9f0c..992e58187 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -362,12 +362,9 @@ 用户信息 新节点通知 SNR - 信噪比(Signal-to-Noise Ratio, SNR)是一种用于通信领域的测量指标,用于量化目标信号与背景噪声的比例。在 Meshtastic 及其他无线系统中,较高的信噪比表示信号更加清晰,从而能够提升数据传输的可靠性和质量。 RSSI - 接收信号强度指示(Received Signal Strength Indicator, RSSI)是一种用于测量天线接收到的信号功率的指标。较高的 RSSI 值通常表示更强、更稳定的连接。 室内空气质量(Indoor Air Quality, IAQ):由 Bosch BME680 传感器测量的相对标尺 IAQ 值,取值范围为 0–500。 设备指标 - 节点地图 定位 最后位置更新 传感器指标 @@ -1117,4 +1114,6 @@ 连接 完成 Meshtastic + 搜索节点 + 选择设备 diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml index 950ffeba8..a6313dae7 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -373,12 +373,9 @@ 使用者資訊 新節點通知 SNR - 信噪比(SNR),用於通訊中量化所需信號與背景噪音水平的指標。在 Meshtastic 及其他無線系統中,信噪比越高表示信號越清晰,可以提高數據傳輸的可靠性和品質。 RSSI - 接收信號強度指示(RSSI)用於測量天線所接收到信號的功率強度。 RSSI 值越高通常代表連線越強且穩定。 (室內空氣品質) 相對尺度 IAQ 值,由 Bosch BME680 測量。值範圍 0–500。 裝置計量資料 - 節點地圖 位置 最後位置更新 環境計量資料 @@ -418,7 +415,6 @@ 1個月 最大值 最小 - 平均 展開圖表 收起圖表 未知年齡 @@ -1187,4 +1183,6 @@ 顯示 Meshtastic 離開 Meshtastic + 過濾器 + 選擇裝置 diff --git a/fastlane/metadata/android/fr-FR/changelogs/default.txt b/fastlane/metadata/android/fr-FR/changelogs/default.txt index 0553de284..a322da020 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/default.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/default.txt @@ -1 +1 @@ -For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +Pour des notes de version détaillées, veuillez visiter : https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file From 14e86b90f124478efda4f3cbe3670be53721da07 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:33:55 -0500 Subject: [PATCH 26/38] =?UTF-8?q?feat(mqtt):=20adopt=20mqttastic-client-km?= =?UTF-8?q?p=200.2.0=20=E2=80=94=20disconnect=20reasons=20+=20Test=20Conne?= =?UTF-8?q?ction=20(#5181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/manager/MqttManagerImpl.kt | 63 +++++++- .../core/model/MqttConnectionState.kt | 41 +++-- .../meshtastic/core/model/MqttProbeStatus.kt | 52 +++++++ .../network/repository/MQTTRepositoryImpl.kt | 37 +++-- .../repository/MQTTRepositoryImplTest.kt | 79 ++++++++-- .../meshtastic/core/repository/MqttManager.kt | 12 ++ .../composeResources/values/strings.xml | 12 ++ .../org/meshtastic/desktop/stub/NoopStubs.kt | 2 +- .../settings/radio/RadioConfigViewModel.kt | 36 +++++ .../radio/component/MQTTConfigItemList.kt | 142 ++++++++++++++++-- .../radio/RadioConfigViewModelTest.kt | 2 +- gradle/libs.versions.toml | 2 +- 12 files changed, 425 insertions(+), 55 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt index 9940db706..5693d343b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt @@ -31,12 +31,17 @@ import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.network.repository.MQTTRepository +import org.meshtastic.core.network.repository.resolveEndpoint import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.mqtt.ConnectionState +import org.meshtastic.mqtt.MqttClient import org.meshtastic.mqtt.MqttException +import org.meshtastic.mqtt.ProbeResult +import org.meshtastic.mqtt.probe import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.proto.ToRadio @@ -52,9 +57,9 @@ class MqttManagerImpl( override val mqttConnectionState: StateFlow = combine(proxyActive, mqttRepository.connectionState) { active, libState -> - if (!active) MqttConnectionState.INACTIVE else libState.toAppState() + if (!active) MqttConnectionState.Inactive else libState.toAppState() } - .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.INACTIVE) + .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.Inactive) override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) { if (mqttMessageFlow?.isActive == true) return @@ -102,9 +107,55 @@ class MqttManagerImpl( } private fun ConnectionState.toAppState(): MqttConnectionState = when (this) { - ConnectionState.DISCONNECTED -> MqttConnectionState.DISCONNECTED - ConnectionState.CONNECTING -> MqttConnectionState.CONNECTING - ConnectionState.CONNECTED -> MqttConnectionState.CONNECTED - ConnectionState.RECONNECTING -> MqttConnectionState.RECONNECTING + is ConnectionState.Connecting -> MqttConnectionState.Connecting + is ConnectionState.Connected -> MqttConnectionState.Connected + is ConnectionState.Reconnecting -> + MqttConnectionState.Reconnecting(attempt = attempt, lastError = lastError?.message) + is ConnectionState.Disconnected -> + reason?.let { MqttConnectionState.Disconnected(reason = it.message) } + ?: MqttConnectionState.Disconnected.Idle + } + + override suspend fun probe( + address: String, + tlsEnabled: Boolean, + username: String?, + password: String?, + ): MqttProbeStatus { + val endpoint = resolveEndpoint(address, tlsEnabled) + val result = + MqttClient.probe(endpoint = endpoint) { + val user = username?.takeUnless { it.isEmpty() } + val pass = password?.takeUnless { it.isEmpty() } + if (user != null) this.username = user + if (pass != null) password(pass) + } + return result.toAppStatus() + } + + private fun ProbeResult.toAppStatus(): MqttProbeStatus = when (this) { + is ProbeResult.Success -> { + val info = serverInfo + val summary = + buildList { + info.assignedClientIdentifier?.let { add("client=$it") } + info.maximumQosOrdinal?.let { add("maxQoS=$it") } + info.serverKeepAliveSeconds?.let { add("keepalive=${it}s") } + } + .joinToString(", ") + .ifEmpty { null } + MqttProbeStatus.Success(serverInfo = summary) + } + is ProbeResult.Rejected -> + MqttProbeStatus.Rejected( + reasonCode = reasonCode.value, + reason = message, + serverReference = serverReference, + ) + is ProbeResult.DnsFailure -> MqttProbeStatus.DnsFailure(message = cause.message) + is ProbeResult.TcpFailure -> MqttProbeStatus.TcpFailure(message = cause.message) + is ProbeResult.TlsFailure -> MqttProbeStatus.TlsFailure(message = cause.message) + is ProbeResult.Timeout -> MqttProbeStatus.Timeout(timeoutMs = durationMs) + is ProbeResult.Other -> MqttProbeStatus.Other(message = cause.message) } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt index 6a5b9ad15..4d3bfca10 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt @@ -16,20 +16,41 @@ */ package org.meshtastic.core.model -/** App-level MQTT proxy connection state, decoupled from the MQTT library's internal type. */ -enum class MqttConnectionState { +/** + * App-level MQTT proxy connection state, decoupled from the MQTT library's internal type. + * + * Modeled as a sealed class so disconnect / reconnect events can carry diagnostic context — the user-facing reason for + * an unexpected disconnect, or the most recent reconnect attempt failure — without requiring downstream consumers to + * depend on the MQTT library's exception types. + */ +sealed class MqttConnectionState { /** The MQTT proxy has not been started (disabled or not yet initialized). */ - INACTIVE, - - /** The MQTT client is not connected to the broker. */ - DISCONNECTED, + data object Inactive : MqttConnectionState() /** The MQTT client is actively connecting to the broker. */ - CONNECTING, + data object Connecting : MqttConnectionState() /** The MQTT client is connected and subscribed to topics. */ - CONNECTED, + data object Connected : MqttConnectionState() - /** The MQTT client lost connection and is attempting to reconnect. */ - RECONNECTING, + /** + * The MQTT client lost connection and is attempting to reconnect. + * + * @property attempt 1-based attempt counter for the current reconnect loop. + * @property lastError Localized message from the most recent reconnect failure, if any. + */ + data class Reconnecting(val attempt: Int = 0, val lastError: String? = null) : MqttConnectionState() + + /** + * The MQTT client is not connected to the broker. + * + * @property reason Localized failure message for an unexpected disconnect, or `null` for the idle / initial / + * intentional-close case (use [Idle]). + */ + data class Disconnected(val reason: String? = null) : MqttConnectionState() { + companion object { + /** Singleton for the idle / no-reason disconnected state. */ + val Idle: Disconnected = Disconnected(reason = null) + } + } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt new file mode 100644 index 000000000..e3cb7c77a --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +/** + * UI-friendly outcome of a one-shot MQTT broker reachability probe. + * + * Mirrors the failure shapes of `org.meshtastic.mqtt.ProbeResult` but stays in the model module so feature/UI code can + * consume the result without depending on the MQTT library. + */ +sealed class MqttProbeStatus { + /** Probe is currently in flight. */ + data object Probing : MqttProbeStatus() + + /** + * Broker accepted the connection. [serverInfo] is a short human-readable summary of any CONNACK properties that are + * useful to surface to the user. + */ + data class Success(val serverInfo: String?) : MqttProbeStatus() + + /** Broker rejected the connection (CONNACK with non-zero reason code). */ + data class Rejected(val reasonCode: Int, val reason: String?, val serverReference: String?) : MqttProbeStatus() + + /** DNS lookup failed. */ + data class DnsFailure(val message: String?) : MqttProbeStatus() + + /** TCP socket could not be opened. */ + data class TcpFailure(val message: String?) : MqttProbeStatus() + + /** TLS handshake failed. */ + data class TlsFailure(val message: String?) : MqttProbeStatus() + + /** Probe exceeded its timeout. */ + data class Timeout(val timeoutMs: Long) : MqttProbeStatus() + + /** Any other / unclassified failure. */ + data class Other(val message: String?) : MqttProbeStatus() +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index 94ab7f0ce..47cfb6f7a 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -65,7 +65,6 @@ class MQTTRepositoryImpl( private const val DEFAULT_TOPIC_LEVEL = "/2/e/" private const val JSON_TOPIC_LEVEL = "/2/json/" private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org" - private const val WEBSOCKET_PATH = "/mqtt" private const val KEEPALIVE_SECONDS = 30 private const val INITIAL_RECONNECT_DELAY_MS = 1000L private const val MAX_RECONNECT_DELAY_MS = 30_000L @@ -74,7 +73,7 @@ class MQTTRepositoryImpl( @Volatile private var client: MqttClient? = null - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected.Idle) override val connectionState: StateFlow = _connectionState.asStateFlow() @OptIn(ExperimentalSerializationApi::class) @@ -89,7 +88,7 @@ class MQTTRepositoryImpl( Logger.i { "MQTT Disconnecting" } val c = client client = null - _connectionState.value = ConnectionState.DISCONNECTED + _connectionState.value = ConnectionState.Disconnected.Idle scope.launch { safeCatching { c?.close() }.onFailure { e -> Logger.w(e) { "MQTT clean disconnect failed" } } } } @@ -102,14 +101,7 @@ class MQTTRepositoryImpl( val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT } ?: DEFAULT_TOPIC_ROOT val rawAddress = mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS - val endpoint = - if (rawAddress.contains("://")) { - MqttEndpoint.parse(rawAddress) - } else { - // Use WebSocket transport on all platforms for firewall/CDN compatibility. - val scheme = if (mqttConfig?.tls_enabled == true) "wss" else "ws" - MqttEndpoint.parse("$scheme://$rawAddress$WEBSOCKET_PATH") - } + val endpoint = resolveEndpoint(rawAddress, mqttConfig?.tls_enabled == true) val newClient = MqttClient(ownerId) { @@ -226,3 +218,26 @@ class MQTTRepositoryImpl( } } } + +/** + * Resolve a user-supplied broker address into an [MqttEndpoint]. + * + * Address resolution rules: + * - If [rawAddress] already contains a URI scheme (`scheme://…`), parse it directly via [MqttEndpoint.parse] and + * respect whatever transport / port the user encoded. + * - Otherwise wrap it as a WebSocket endpoint (`ws[s]://host${WEBSOCKET_PATH}`) so the proxy works over CDNs and + * firewall-restricted networks where raw 1883/8883 may be blocked. The scheme is `wss` when [tlsEnabled] is `true`, + * `ws` otherwise. + * + * Extracted as a top-level function so [MQTTRepositoryImplTest] can exercise every branch without spinning up the full + * repository, and so `MqttManagerImpl` (in `:core:data`) can reuse the same parsing rules for the probe API. Visibility + * is `public` because Kotlin's `internal` is scoped per Gradle module. + */ +fun resolveEndpoint(rawAddress: String, tlsEnabled: Boolean): MqttEndpoint = if (rawAddress.contains("://")) { + MqttEndpoint.parse(rawAddress) +} else { + val scheme = if (tlsEnabled) "wss" else "ws" + MqttEndpoint.parse("$scheme://$rawAddress$WEBSOCKET_PATH") +} + +private const val WEBSOCKET_PATH = "/mqtt" diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt index 73e096da9..26b83a420 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt @@ -18,25 +18,82 @@ package org.meshtastic.core.network.repository import kotlinx.serialization.json.Json import org.meshtastic.core.model.MqttJsonPayload +import org.meshtastic.mqtt.MqttEndpoint import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertTrue class MQTTRepositoryImplTest { - @Test - fun `test address parsing logic`() { - val address1 = "mqtt.example.com:1883" - val (host1, port1) = address1.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) } - assertEquals("mqtt.example.com", host1) - assertEquals(1883, port1) + // region resolveEndpoint — every behavioral branch of address parsing. - val address2 = "mqtt.example.com" - val (host2, port2) = address2.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) } - assertEquals("mqtt.example.com", host2) - assertEquals(1883, port2) + @Test + fun `bare host without scheme is wrapped as ws WebSocket on the standard port`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com/mqtt", ws.url) } + @Test + fun `bare host with TLS enabled is upgraded to wss`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = true) + + val ws = assertIs(endpoint) + assertEquals("wss://broker.example.com/mqtt", ws.url) + } + + @Test + fun `host with explicit port is preserved when wrapped`() { + val endpoint = resolveEndpoint(rawAddress = "broker.example.com:9001", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com:9001/mqtt", ws.url) + } + + @Test + fun `address with ws scheme is parsed as-is and tls flag is ignored`() { + // tlsEnabled is intentionally true here — when the user supplies a full URL we + // must honor whatever scheme they provided, not silently upgrade it. + val endpoint = resolveEndpoint(rawAddress = "ws://broker.example.com:8080/custom-path", tlsEnabled = true) + + val ws = assertIs(endpoint) + assertEquals("ws://broker.example.com:8080/custom-path", ws.url) + } + + @Test + fun `address with wss scheme is parsed as-is`() { + val endpoint = resolveEndpoint(rawAddress = "wss://broker.example.com/secure-mqtt", tlsEnabled = false) + + val ws = assertIs(endpoint) + assertEquals("wss://broker.example.com/secure-mqtt", ws.url) + } + + @Test + fun `address with mqtt tcp scheme is parsed as Tcp endpoint`() { + val endpoint = resolveEndpoint(rawAddress = "mqtt://broker.example.com:1883", tlsEnabled = false) + + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(1883, tcp.port) + assertEquals(false, tcp.tls) + } + + @Test + fun `address with mqtts tcp scheme is parsed as Tcp endpoint with tls true`() { + val endpoint = resolveEndpoint(rawAddress = "mqtts://broker.example.com:8883", tlsEnabled = false) + + val tcp = assertIs(endpoint) + assertEquals("broker.example.com", tcp.host) + assertEquals(8883, tcp.port) + assertEquals(true, tcp.tls) + } + + // endregion + + // region MqttJsonPayload — keep the existing JSON contract tests. + @Test fun `test json payload parsing`() { val jsonStr = @@ -72,4 +129,6 @@ class MQTTRepositoryImplTest { assertTrue(jsonStr.contains("\"from\":12345678")) assertTrue(jsonStr.contains("\"payload\":\"Hello World\"")) } + + // endregion } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt index d91ae7080..6701514f8 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.repository import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.proto.MqttClientProxyMessage /** Interface for managing MQTT proxy communication. */ @@ -33,4 +34,15 @@ interface MqttManager { /** Handles an MQTT proxy message from the radio. */ fun handleMqttProxyMessage(message: MqttClientProxyMessage) + + /** + * Probe an MQTT broker to verify connectivity and credentials without joining the proxy lifecycle. Intended for UI + * "Test Connection" affordances. + * + * @param address Raw broker address as the user would type it (host, host:port, or full URL). + * @param tlsEnabled `true` to upgrade bare addresses to `wss://` (ignored when [address] already has a scheme). + * @param username Optional MQTT username. + * @param password Optional MQTT password. + */ + suspend fun probe(address: String, tlsEnabled: Boolean, username: String?, password: String?): MqttProbeStatus } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 481a94b78..505d80821 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -636,9 +636,21 @@ MQTT Config Inactive Disconnected + Disconnected — %1$s Connecting… Connected Reconnecting… + Reconnecting (attempt %1$d) — %2$s + Test connection + Probing broker… + Reachable. Broker accepted credentials. + Reachable (%1$s) + Broker rejected: %1$s + Host not found + Cannot reach broker (TCP) + TLS handshake failed + Timed out after %1$d ms + Connection failed MQTT enabled Address Username diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index f366d821b..707dfaf03 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -164,7 +164,7 @@ class NoopMQTTRepository : MQTTRepository { override fun publish(topic: String, data: ByteArray, retained: Boolean) {} - override val connectionState = MutableStateFlow(MqttConnectionState.DISCONNECTED) + override val connectionState = MutableStateFlow(MqttConnectionState.Disconnected.Idle) } // endregion diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index e443a3f75..c59f00b56 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -20,14 +20,17 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel @@ -44,6 +47,7 @@ import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position @@ -144,6 +148,38 @@ open class RadioConfigViewModel( /** MQTT proxy connection state for the settings UI. */ val mqttConnectionState: StateFlow = mqttManager.mqttConnectionState + private val _mqttProbeStatus = MutableStateFlow(null) + + /** Latest result from a [probeMqttConnection] call, or `null` if no probe has been run. */ + val mqttProbeStatus: StateFlow = _mqttProbeStatus.asStateFlow() + + private var probeJob: Job? = null + + /** + * Run a one-shot reachability/credentials probe against an MQTT broker. Cancels any in-flight probe before starting + * a new one. Result is exposed via [mqttProbeStatus]. + */ + fun probeMqttConnection(address: String, tlsEnabled: Boolean, username: String?, password: String?) { + probeJob?.cancel() + _mqttProbeStatus.value = MqttProbeStatus.Probing + probeJob = + viewModelScope.launch { + val result = + runCatching { mqttManager.probe(address, tlsEnabled, username, password) } + .getOrElse { e -> + Logger.w(e) { "MQTT probe threw" } + MqttProbeStatus.Other(message = e.message) + } + _mqttProbeStatus.value = result + } + } + + /** Clear the latest probe result (e.g. when the user edits the address). */ + fun clearMqttProbeStatus() { + probeJob?.cancel() + _mqttProbeStatus.value = null + } + private val destNumFlow = MutableStateFlow(savedStateHandle.get("destNum")) fun initDestNum(id: Int?) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt index 972a9d43f..e1f407679 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt @@ -21,12 +21,15 @@ package org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -36,6 +39,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction @@ -44,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.address import org.meshtastic.core.resources.default_mqtt_address @@ -53,11 +58,23 @@ import org.meshtastic.core.resources.map_reporting import org.meshtastic.core.resources.mqtt import org.meshtastic.core.resources.mqtt_config import org.meshtastic.core.resources.mqtt_enabled +import org.meshtastic.core.resources.mqtt_probe_dns_failure +import org.meshtastic.core.resources.mqtt_probe_other_failure +import org.meshtastic.core.resources.mqtt_probe_rejected +import org.meshtastic.core.resources.mqtt_probe_running +import org.meshtastic.core.resources.mqtt_probe_success +import org.meshtastic.core.resources.mqtt_probe_success_with_info +import org.meshtastic.core.resources.mqtt_probe_tcp_failure +import org.meshtastic.core.resources.mqtt_probe_timeout +import org.meshtastic.core.resources.mqtt_probe_tls_failure import org.meshtastic.core.resources.mqtt_status_connected import org.meshtastic.core.resources.mqtt_status_connecting import org.meshtastic.core.resources.mqtt_status_disconnected +import org.meshtastic.core.resources.mqtt_status_disconnected_with_reason import org.meshtastic.core.resources.mqtt_status_inactive import org.meshtastic.core.resources.mqtt_status_reconnecting +import org.meshtastic.core.resources.mqtt_status_reconnecting_with_attempt +import org.meshtastic.core.resources.mqtt_test_connection import org.meshtastic.core.resources.password import org.meshtastic.core.resources.proxy_to_client_enabled import org.meshtastic.core.resources.root_topic @@ -75,6 +92,7 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val destNode by viewModel.destNode.collectAsStateWithLifecycle() val mqttProxyState by viewModel.mqttConnectionState.collectAsStateWithLifecycle() + val probeStatus by viewModel.mqttProbeStatus.collectAsStateWithLifecycle() val destNum = destNode?.num val mqttConfig = state.moduleConfig.mqtt ?: ModuleConfig.MQTTConfig() val formState = rememberConfigState(initialValue = mqttConfig) @@ -119,16 +137,13 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.address), - value = formState.value.address, - maxSize = 63, // address max_size:64 + MqttAddressAndProbe( enabled = state.connected, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(address = it) }, + formState = formState, + probeStatus = probeStatus, + focusManager = focusManager, + onProbe = viewModel::probeMqttConnection, + onClearProbe = viewModel::clearMqttProbeStatus, ) HorizontalDivider() EditTextPreference( @@ -241,13 +256,26 @@ private val GreenColor = Color(0xFF4CAF50) private fun MqttStatusRow(state: MqttConnectionState) { val (label, color) = when (state) { - MqttConnectionState.INACTIVE -> + is MqttConnectionState.Inactive -> stringResource(Res.string.mqtt_status_inactive) to MaterialTheme.colorScheme.outline - MqttConnectionState.DISCONNECTED -> - stringResource(Res.string.mqtt_status_disconnected) to MaterialTheme.colorScheme.error - MqttConnectionState.CONNECTING -> stringResource(Res.string.mqtt_status_connecting) to AmberColor - MqttConnectionState.CONNECTED -> stringResource(Res.string.mqtt_status_connected) to GreenColor - MqttConnectionState.RECONNECTING -> stringResource(Res.string.mqtt_status_reconnecting) to AmberColor + is MqttConnectionState.Disconnected -> { + val text = + state.reason?.let { stringResource(Res.string.mqtt_status_disconnected_with_reason, it) } + ?: stringResource(Res.string.mqtt_status_disconnected) + text to MaterialTheme.colorScheme.error + } + is MqttConnectionState.Connecting -> stringResource(Res.string.mqtt_status_connecting) to AmberColor + is MqttConnectionState.Connected -> stringResource(Res.string.mqtt_status_connected) to GreenColor + is MqttConnectionState.Reconnecting -> { + val err = state.lastError + val text = + if (err != null) { + stringResource(Res.string.mqtt_status_reconnecting_with_attempt, state.attempt, err) + } else { + stringResource(Res.string.mqtt_status_reconnecting) + } + text to AmberColor + } } Row( verticalAlignment = Alignment.CenterVertically, @@ -262,3 +290,87 @@ private fun MqttStatusRow(state: MqttConnectionState) { ) } } + +@Composable +private fun MqttAddressAndProbe( + enabled: Boolean, + formState: ConfigState, + probeStatus: MqttProbeStatus?, + focusManager: FocusManager, + onProbe: (address: String, tlsEnabled: Boolean, username: String, password: String) -> Unit, + onClearProbe: () -> Unit, +) { + EditTextPreference( + title = stringResource(Res.string.address), + value = formState.value.address, + maxSize = 63, // address max_size:64 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + formState.value = formState.value.copy(address = it) + onClearProbe() + }, + ) + HorizontalDivider() + MqttProbeRow( + enabled = enabled && formState.value.address.isNotBlank(), + status = probeStatus, + onTestClick = { + focusManager.clearFocus() + onProbe( + formState.value.address, + formState.value.tls_enabled, + formState.value.username, + formState.value.password, + ) + }, + ) +} + +@Composable +private fun MqttProbeRow(enabled: Boolean, status: MqttProbeStatus?, onTestClick: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Button(onClick = onTestClick, enabled = enabled && status !is MqttProbeStatus.Probing) { + Text(stringResource(Res.string.mqtt_test_connection)) + } + val (probeText, probeColor) = status.toLabel() ?: return@Row + Text(text = probeText, style = MaterialTheme.typography.bodySmall, color = probeColor) + } + } +} + +@Composable +private fun MqttProbeStatus?.toLabel(): Pair? = when (this) { + null -> null + is MqttProbeStatus.Probing -> + stringResource(Res.string.mqtt_probe_running) to MaterialTheme.colorScheme.onSurfaceVariant + is MqttProbeStatus.Success -> { + val text = + serverInfo?.let { stringResource(Res.string.mqtt_probe_success_with_info, it) } + ?: stringResource(Res.string.mqtt_probe_success) + text to GreenColor + } + is MqttProbeStatus.Rejected -> + stringResource(Res.string.mqtt_probe_rejected, reason ?: reasonCode.toString()) to + MaterialTheme.colorScheme.error + is MqttProbeStatus.DnsFailure -> + stringResource(Res.string.mqtt_probe_dns_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.TcpFailure -> + stringResource(Res.string.mqtt_probe_tcp_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.TlsFailure -> + stringResource(Res.string.mqtt_probe_tls_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.Timeout -> + stringResource(Res.string.mqtt_probe_timeout, timeoutMs.toInt()) to MaterialTheme.colorScheme.error + is MqttProbeStatus.Other -> + stringResource(Res.string.mqtt_probe_other_failure) to MaterialTheme.colorScheme.error +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 6e11f6b92..c1b7d8a9e 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -124,7 +124,7 @@ class RadioConfigViewModelTest { MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) every { mqttManager.mqttConnectionState } returns - MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.INACTIVE) + MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive) every { uiPrefs.showQuickChat } returns MutableStateFlow(false) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe96dc45e..91f2ea6b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,7 +74,7 @@ spotless = "8.4.0" wire = "6.2.0" vico = "3.1.0" kable = "0.42.0" -mqttastic = "0.1.0" +mqttastic = "0.2.0" jmdns = "3.6.3" qrcode-kotlin = "4.5.0" From 4257e7b7e4483b605497b97d29054b0affa977d1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:41:36 -0500 Subject: [PATCH 27/38] chore(deps): split androidx-compose version ref from CMP (#5183) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/renovate.json | 9 +++++++++ gradle/libs.versions.toml | 16 +++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index dda9390c3..1faa1a4ad 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -56,6 +56,15 @@ "changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}", "automerge": true }, + { + "description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)", + "groupName": "compose-multiplatform", + "matchPackageNames": [ + "/^org\\.jetbrains\\.compose/", + "androidx.compose.runtime:runtime-tracing", + "androidx.compose.ui:ui-test-manifest" + ] + }, { "description": "Restrict sensitive infrastructure to manual minor updates", "matchUpdateTypes": [ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91f2ea6b7..73286aff3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,11 +35,17 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" compose-multiplatform-material3 = "1.11.0-alpha06" +# `androidx-compose-bom-aligned` tracks androidx.compose.{runtime,ui} test/tracing +# artifacts that ship in lockstep with CMP. Kept as a separate version ref so Renovate +# can bump androidx releases (which often land first) without dragging the +# `org.jetbrains.compose:*` artifacts and Gradle plugin to a version JetBrains +# hasn't published yet (see PR #5180). Should normally match `compose-multiplatform`; +# AndroidCompose.kt's resolutionStrategy force-aligns these groups to the CMP version +# at resolution time regardless of the declared value here. +androidx-compose-bom-aligned = "1.11.0-beta02" # `androidx-compose-material` (M2) is independent of CMP and pinned separately # because some third-party libs (maps-compose-widgets, datadog) drag in -# unversioned material transitives. Test/tracing artifacts in the -# androidx.compose.{runtime,ui} groups MUST track CMP — use compose-multiplatform -# as their version ref, not a separate pin. +# unversioned material transitives. androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" @@ -122,8 +128,8 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) -androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" } -androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity) +androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-bom-aligned" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-bom-aligned" } # Required by Robolectric Compose tests (registers ComponentActivity) # Compose Multiplatform compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" } From 68a414b75bc95e9e3d168aa5b8f8f513a4db1678 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:00:34 -0500 Subject: [PATCH 28/38] chore(deps): update compose-multiplatform to v1.11.0-rc01 (#5184) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73286aff3..79436cd48 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ compose-multiplatform-material3 = "1.11.0-alpha06" # hasn't published yet (see PR #5180). Should normally match `compose-multiplatform`; # AndroidCompose.kt's resolutionStrategy force-aligns these groups to the CMP version # at resolution time regardless of the declared value here. -androidx-compose-bom-aligned = "1.11.0-beta02" +androidx-compose-bom-aligned = "1.11.0-rc01" # `androidx-compose-material` (M2) is independent of CMP and pinned separately # because some third-party libs (maps-compose-widgets, datadog) drag in # unversioned material transitives. From 84fe24467f98e637213823be284f3e04d943ee69 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:11:32 -0500 Subject: [PATCH 29/38] 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 30/38] 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 31/38] 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 32/38] 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 33/38] 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 34/38] 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 35/38] 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 36/38] 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 37/38] 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 38/38] 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) {