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/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/.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/.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/.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/.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/.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/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)
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/proguard-rules.pro b/app/proguard-rules.pro
index 8340fdd10..de2b3144c 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,65 +1,45 @@
# ============================================================================
# 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.
+#
+# 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 stack 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
-# ---- 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 --------------------------------------------
-
-# 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.
--keep class androidx.compose.runtime.** { *; }
--keep class androidx.compose.ui.** { *; }
--keep class androidx.compose.animation.core.** { *; }
--keep class androidx.compose.animation.** { *; }
-
-# ---- 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.** { *; }
+# 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/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/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/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/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/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
similarity index 63%
rename from feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
index 0a35599f5..04f0350c8 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025-2026 Meshtastic LLC
+ * 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
@@ -14,9 +14,13 @@
* 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
+package org.meshtastic.app.di
-import org.meshtastic.core.navigation.SettingsRoute
+import org.koin.core.annotation.KoinApplication
-actual fun getAboutLibrariesJson(): String =
- SettingsRoute::class.java.getResource("/aboutlibraries.json")?.readText() ?: ""
+/**
+ * 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/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/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/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/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)
}
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/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/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro
new file mode 100644
index 000000000..8d0d8efde
--- /dev/null
+++ b/config/proguard/shared-rules.pro
@@ -0,0 +1,166 @@
+# ============================================================================
+# 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 ----------------------------------------------------
+# 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) -----------------------------------
+
+# 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 * { *; }
+-keep @org.koin.core.annotation.KoinViewModel class * { *; }
+
+# ---- 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 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).
+-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's own consumer rules (from androidx.room3) keep DAOs, entities,
+# generated _Impl classes, and TypeConverters referenced from the database.
+
+# ---- SQLite bundled --------------------------------------------------------
+# androidx.sqlite ships consumer rules.
+
+# ---- Ktor (ServiceLoader + plugin discovery) --------------------------------
+
+# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory
+# implementations reflectively via ServiceLoader).
+-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; }
+
+# ---- Coil 3 (image loading) -------------------------------------------------
+# coil3 ships consumer rules.
+
+# ---- Kable BLE --------------------------------------------------------------
+# com.juul.kable ships consumer rules; if release builds fail with missing
+# Kable classes, restore a narrow keep for the specific reflection-loaded type.
+
+# ---- Compose Multiplatform resources ----------------------------------------
+
+# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.).
+# Without these the fdroid flavor has crashed at startup with a misleading
+# URLDecodeException due to R8 exception-class merging.
+-keep class org.jetbrains.compose.resources.** { *; }
+-keep class org.meshtastic.core.resources.Res { *; }
+-keepclassmembers class org.meshtastic.core.resources.Res$* { *; }
+
+# ---- AboutLibraries ---------------------------------------------------------
+# com.mikepenz.aboutlibraries ships consumer rules.
+
+# ---- Multiplatform Markdown Renderer ----------------------------------------
+# com.mikepenz.markdown ships consumer rules.
+
+# ---- QR Code Kotlin ---------------------------------------------------------
+
+-keep class io.github.g0dkar.qrcode.** { *; }
+-dontwarn io.github.g0dkar.qrcode.**
+-keep class qrcode.** { *; }
+-dontwarn qrcode.**
+
+# ---- Kermit logging ---------------------------------------------------------
+# co.touchlab.kermit ships consumer rules.
+
+# ---- Okio -------------------------------------------------------------------
+# okio ships consumer rules.
+
+# ---- DataStore --------------------------------------------------------------
+# androidx.datastore ships consumer rules.
+
+# ---- Paging -----------------------------------------------------------------
+# androidx.paging ships consumer rules.
+
+# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) -----------------
+# androidx.lifecycle and androidx.navigation3 ship consumer rules.
+
+# ---- Meshtastic shared model ------------------------------------------------
+# core.model types are reached via static references from Koin-wired graphs,
+# Room entities, and kotlinx-serialization @Serializable companions — all of
+# which have their own keep rules above.
+
+# ---- Compose Runtime & Animation --------------------------------------------
+
+# Defence-in-depth: prevent tree-shaking of Compose infrastructure classes that
+# are referenced indirectly through compiler-generated state machines. Applies
+# to BOTH R8 (Android app) and ProGuard (desktop distribution).
+#
+# Why shared: CMP 1.11 ships consumer rules with -assumenosideeffects on
+# Composer.() / ComposerImpl.() and -assumevalues on
+# ComposeRuntimeFlags / ComposeStackTraceMode. If the optimizer runs (R8 full
+# mode on Android, ProGuard with optimize.set(true) on desktop) these call
+# sites can be rewritten even when the target classes are kept, causing the
+# recomposer / frame-clock / animation state machines to silently freeze on
+# the first frame. -dontoptimize (set per-host) is the primary defence; these
+# keep rules are a safety net against future toolchain changes. See #5146.
+-keep class androidx.compose.runtime.** { *; }
+-keep class androidx.compose.ui.** { *; }
+-keep class androidx.compose.animation.core.** { *; }
+-keep class androidx.compose.animation.** { *; }
+-keep class androidx.compose.foundation.** { *; }
+-keep class androidx.compose.material3.** { *; }
diff --git a/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/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/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/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/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/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/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/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..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
@@ -20,15 +20,28 @@ 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.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
@@ -40,18 +53,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 +88,7 @@ class MqttManagerImpl(
mqttMessageFlow?.cancel()
mqttMessageFlow = null
}
+ proxyActive.value = false
}
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
@@ -79,4 +105,57 @@ class MqttManagerImpl(
else -> {}
}
}
+
+ private fun ConnectionState.toAppState(): MqttConnectionState = when (this) {
+ 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/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/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/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/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/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..4d3bfca10
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ *
+ * 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). */
+ data object Inactive : MqttConnectionState()
+
+ /** The MQTT client is actively connecting to the broker. */
+ data object Connecting : MqttConnectionState()
+
+ /** The MQTT client is connected and subscribed to topics. */
+ data object Connected : MqttConnectionState()
+
+ /**
+ * 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/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 {
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/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/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 cfc84c668..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
@@ -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
@@ -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(
@@ -396,14 +400,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/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/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..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
@@ -35,20 +35,22 @@ 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)
+ 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/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..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
@@ -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,16 @@ 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 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.Idle)
+ override val connectionState: StateFlow = _connectionState.asStateFlow()
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
@@ -77,25 +82,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.Idle
+ 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 +100,105 @@ 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 = resolveEndpoint(rawAddress, mqttConfig?.tls_enabled == true)
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,18 +208,36 @@ 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" } }
}
}
}
}
+
+/**
+ * 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/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/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/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..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
@@ -74,11 +74,15 @@ open class TcpRadioTransport(
transport.start(address)
}
- override fun close() {
+ override suspend fun close() {
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 d43063d52..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)
}
}
}
@@ -154,7 +157,7 @@ private constructor(
serialPort = null
}
- override fun close() {
+ override suspend fun close() {
Logger.d { "[$portName] Closing serial transport" }
readJob?.cancel()
readJob = null
@@ -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/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/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/core/proto/src/main/proto b/core/proto/src/main/proto
index 940ac382a..4d5b500df 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit 940ac382a7d143040da5a880237f84c48ee31f2b
+Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c
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/MqttManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt
index 7ebfa0521..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
@@ -16,10 +16,16 @@
*/
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. */
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)
@@ -28,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/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-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml
index fc61e78d4..2e4eaf53c 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 @@
الرسائل
إعدادات لورا
الجهة
+ انقطع الاتصال
استغرق وقت طويل
المسافة
الإعدادات
@@ -173,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 aee9e7120..cb615de37 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
Рэгіён
+ Адлучана
+ Злучаны
Імя карыстальніка
Пароль
Уключана
@@ -219,4 +221,6 @@
Чырвоны
Сіні
Зялёны
+ 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..f69e137d9 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
записа
@@ -317,12 +324,9 @@
Публичният ключ не съвпада със записания ключ. Можете да премахнете възела и да го оставите да обмени ключове отново, но това може да показва по-сериозен проблем със сигурността. Свържете се с потребителя чрез друг надежден канал, за да определите дали промяната на ключа се дължи на фабрично нулиране или друго умишлено действие.
Известия за нови възли
SNR
- Съотношение сигнал/шум, мярка, използвана в комуникациите за количествено определяне на нивото на желания сигнал спрямо нивото на фоновия шум. В Meshtastic и други безжични системи, по-високото съотношение сигнал/шум показва по-ясен сигнал, който може да подобри надеждността и качеството на предаване на данни.
RSSI
- Индикатор за силата на получения сигнал - измерване, използвано за определяне на нивото на получения сигнал, приемано от антената. По-високата стойност на RSSI обикновено показва по-силна и по-стабилна връзка.
(Качество на въздуха в помещенията) относителна скала за IAQ, стойностите са измерени с Bosch BME680. Диапазон на стойностите 0–500.
Метрики на устройството
- Карта на възела
Позиция
Последна актуализация на позицията
Показатели на околната среда
@@ -362,7 +366,6 @@
1М
Макс
Мин
- Ср
Разгъване на диаграмата
Свиване на диаграмата
Неизвестна възраст
@@ -483,6 +486,17 @@
Честотен слот
Игнориране на MQTT
Конфигуриране на MQTT
+ Неактивен
+ Прекъсната връзка
+ Свързване…
+ Свързано
+ Повторно свързване…
+ Повторно свързване (опит %1$d) — %2$s
+ Тестване на връзката
+ Достъпен. Брокерът е приел идентификационните данни.
+ Достъпен (%1$s)
+ Хостът не е намерен
+ Връзката е неуспешна
MQTT е активиран
Адрес
Потребителско име
@@ -960,4 +974,9 @@
Въведете или изберете мрежа
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..22b52e28e 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
@@ -199,4 +200,6 @@
+ 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 a0ccafea5..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í
@@ -525,6 +522,8 @@
Ignorovat MQTT
OK do MQTT
Nastavení MQTT
+ Odpojeno
+ Připojeno
MQTT povoleno
Adresa
Uživatelské jméno
@@ -968,4 +967,6 @@
Poznámka
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 4657dc925..4755515ad 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
@@ -378,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
@@ -430,7 +432,6 @@
1 Monat
Maximal
Minimum
- Durchschnitt
Diagramm einblenden
Diagramm ausblenden
Alter unbekannt
@@ -582,6 +583,9 @@
Ausgabedauer (GPIO)
Nervige Verzögerung (Sekunden)
Klingelton
+ Importierter Klingelton
+ Datei ist leer
+ Fehler beim Importieren: %1$s
Wiedergabe
I2S als Buzzer verwenden
LoRa
@@ -605,6 +609,23 @@
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
Benutzername
@@ -720,6 +741,7 @@
Regen (24 Std.)
Gewicht
Strahlung
+ 1-Wire Temperature
Luftqualität im Innenbereich (IAQ)
URL
@@ -1199,4 +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
diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml
index 88feab55e..8386ac2ea 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
Περιφέρεια
+ Αποσυνδεδεμένο
Διεύθυνση
Όνομα χρήστη
Κωδικός πρόσβασης
@@ -200,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 7b9ca263e..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
@@ -492,6 +489,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
@@ -836,4 +835,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Verde
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 651eb5d2f..c2e327629 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,6 +609,23 @@
Keela MQTT
Ok MQTTi
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
@@ -1208,4 +1221,21 @@
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
+ 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 3fdd6afaf..f9da71dea 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,6 +609,23 @@
Ohita MQTT
MQTT päällä
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
@@ -1209,4 +1222,21 @@
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
+ 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 af88601ba..f4afeef5c 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,6 +609,13 @@
Ignorer MQTT
Transmission des paquets vers MQTT
Configuration MQTT
+ Inactif
+ Déconnecté
+ Connexion…
+ Connecté
+ Reconnexion…
+ Test de la connexion
+ Échec de la connexion
MQTT activé
Adresse
Nom d'utilisateur
@@ -625,6 +687,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
@@ -659,8 +723,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
@@ -677,6 +748,7 @@
Horodatage
En-tête
Vitesse
+ %1$d Km/h
Sats
Alt
Fréq
@@ -742,6 +814,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.
@@ -794,7 +871,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.
@@ -849,6 +933,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
@@ -856,6 +942,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.
@@ -949,6 +1039,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.
@@ -1025,8 +1116,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
@@ -1040,10 +1133,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 a081daff2..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
@@ -213,6 +210,7 @@
Céimeanna i dtreo %1$d Céimeanna ar ais %2$d
Réigiún
+ Na ceangailte
Am tráth
Sáth
@@ -229,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 cc3c02597..dc751d2e9 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
@@ -164,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 3afe39071..502d64056 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 @@
בדיקת מסלול
הודעות
אזור
+ מנותק
מרחק
הגדרות
@@ -147,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 f049338ae..114c3ed9a 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
@@ -167,4 +168,6 @@
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 7c4fc0f24..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
@@ -201,6 +198,7 @@
Direk
Hops vèsus %1$d Hops tounen %2$d
Rejyon
+ Dekonekte
Tan pase
Distans
@@ -217,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 c8d27cf4a..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
@@ -515,6 +512,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
@@ -850,4 +849,6 @@
Kék
Zöld
Csatlakozás
+ Meshtastic
+ Filter
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 350db6cb2..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
@@ -560,6 +557,8 @@
Ignora MQTT
OK per MQTT
Configurazione MQTT
+ Disconnesso
+ Connesso
MQTT abilitato
Indirizzo
Username
@@ -960,4 +959,6 @@
Note
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 490a0a2ec..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。
- ノードマップ
位置
管理
リモート管理
@@ -424,6 +421,8 @@
PAファン無効
MQTT を無視
MQTT設定
+ 切断
+ 接続済
MQTTを有効化
アドレス
ユーザー名
@@ -652,4 +651,6 @@
トラフィック管理設定
モジュール有効
接続
+ 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..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.
- 노드 지도
위치
최근 위치 업데이트
관리
@@ -373,6 +370,8 @@
PA fan 비활성화됨
MQTT로 부터 수신 무시
MQTT 설정
+ 연결 끊김
+ 연결됨
MQTT 활성화
서버 주소
사용자명
@@ -539,4 +538,6 @@
파랑
초록
연결
+ Meshtastic
+ 필터
diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
index 9592d8b14..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
@@ -217,6 +216,7 @@
Skambučio simbolis!
Raudona
Regionas
+ Atsijungta
Viešasis raktas
Privatus raktas
Baigėsi laikas
@@ -237,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 ee07fb52b..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
@@ -312,6 +309,8 @@
Inkomende negeren
Negeer MQTT
MQTT Configuratie
+ Niet verbonden
+ Verbonden
MQTT ingeschakeld
Adres
Gebruikersnaam
@@ -416,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 2ecd2a425..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
@@ -222,6 +219,7 @@
Kopier
Varsel, bjellekarakter!
Region
+ Frakoblet
Offentlig nøkkel
Privat nøkkel
Tidsavbrudd
@@ -240,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 394a20bd3..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
@@ -499,6 +496,8 @@
Zignoruj MQTT
Ok dla MQTT
Konfiguracja MQTT
+ Rozłączono
+ Połączony
Włącz MQTT
Adres
Nazwa użytkownika
@@ -749,4 +748,6 @@
Moduł Włączony
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 7e753eefc..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
@@ -382,6 +379,8 @@
Ventilador do PA desativado
Ignorar MQTT
Configurações MQTT
+ Desconectado
+ Conectado
MQTT habilitado
Endereço
Nome de usuário
@@ -665,4 +664,6 @@
Azul
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 0cc07b820..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
@@ -366,6 +363,8 @@
Ignorar entrada
Ignorar MQTT
Configuração MQTT
+ Desconectado
+ Ligado
MQTT ativo
Endereço
Utilizador
@@ -515,4 +514,6 @@
Azul
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 7cf64363b..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ă
@@ -599,6 +595,8 @@
Ignoră MQTT
Acceptă MQTT
Configurare MQTT
+ Deconectat
+ Conectat
MQTT activat
Adresă
Nume de utilizator
@@ -1168,4 +1166,6 @@
Introdu sau selecteaza o retea
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 fd964e294..8d4590e82 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 @@
1М
Макс
Мин
- Сред
Развернуть диаграмму
Свернуть диаграмму
Неизвестный возраст
@@ -621,6 +617,23 @@
Игнорировать MQTT
ОК в MQTT
Настройка MQTT
+ Неактивно
+ Отключено
+ Отключено — %1$s
+ Подключение...
+ Подключено
+ Переподключение...
+ Переподключение (попытка %1$d) — %2$s
+ Проверить соединение
+ Проверяем брокер…
+ Доступно. Брокер принял учетные данные.
+ Доступно (%1$s)
+ Брокер отклонен: %1$s
+ Узел не найден
+ Не удается подключиться к брокеру (TCP)
+ Сбой TLS-рукопожатия
+ Тайм-аут после %1$d мс
+ Соединение не удалось
MQTT включен
Адрес
Имя пользователя
@@ -1224,4 +1237,21 @@
Введите или выберите сеть
Wi-Fi успешно настроен!
Не удалось применить настройку Wi-Fi
+ Meshtastic Desktop
+ Показать Meshtastic
+ Выход
+ Meshtastic
+ Экспорт пакета данных TAK
+ Очистить часовой пояс
+ Фильтр
+ Удалить фильтр
+ Показать легенду качества воздуха
+ Показать статус сообщения
+ Отправить ответ
+ Скопировать сообщение
+ Выбрать сообщение
+ Удалить сообщение
+ Отреагировать эмодзи
+ Выберите устройство
+ Выбрать сеть
diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
index 257154144..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
@@ -362,6 +359,8 @@
LoRa
Šírka pásma
Región
+ Odpojené
+ Pripojený
Adresa
Používateľské meno
Heslo
@@ -427,4 +426,6 @@
Červená
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 8025c4751..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
@@ -226,6 +223,7 @@
Kopiraj
Znak opozorilnega zvonca!
Regija
+ Prekinjeno
Javni ključ
Zasebni ključ
Časovna omejitev
@@ -244,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 e70391f4d..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
@@ -202,6 +199,7 @@
Hops drejt %1$d Hops prapa %2$d
訊息
Rajon
+ I shkëputur
Koha e skaduar
Distanca
@@ -218,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 21a9c14c1..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
@@ -349,6 +346,8 @@
Игнориши MQTT
Позитиван за MQTT
MQTT подешавања
+ Raskačeno
+ Блутут повезан
Адреса
Корисничко име
Лозинка
@@ -430,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 516a963f4..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.
Метрика уређаја
- Мапа чворова
Позиција
Метрике сензора
Администрација
@@ -349,6 +346,8 @@
Игнориши MQTT
Позитиван за MQTT
MQTT подешавања
+ Раскачено
+ Блутут повезан
Адреса
Корисничко име
Лозинка
@@ -430,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 fce685c0a..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
@@ -339,12 +340,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
@@ -373,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
@@ -529,6 +528,10 @@
Ignorera MQTT
Ok till MQTT
MQTT-konfiguration
+ Frånkopplad
+ Ansluten
+ Testa anslutningen
+ Anslutningen misslyckades
MQTT är aktiverat
Adress
Användarnamn
@@ -945,4 +948,7 @@
Modul aktiverad
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 cbd1be2ae..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
@@ -366,6 +363,8 @@
PA fanı devre dışı
MQTT'yi Yoksay
MQTT Yapılandırması
+ Bağlantı kesildi
+ Bağlandı
MQTT etkin
Adres
Kullanıcı adı
@@ -546,4 +545,6 @@
Mavi
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 e92552e55..c9a86af43 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 зазвичай вказує на міцніше та стабільніше з'єднання.
Показники пристрою
- Мапа вузлів
Місцезнаходження
Показники довкілля
Адміністрування
@@ -401,6 +399,9 @@
Перевизначити частоту
Ігнорувати MQTT
Налаштування MQTT
+ Відключено
+ Під’єднано
+ Перевірка зʼєднання
MQTT увімкнений
Адреса
Ім'я користувача
@@ -721,4 +722,7 @@
Зелений
Під’єднатися
Готово
+ Meshtastic
+ Фільтри
+ Оберіть пристрій
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index 9d6e0fbf6..7fff0db20 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。
设备指标
- 节点地图
定位
最后位置更新
传感器指标
@@ -568,6 +565,9 @@
忽略 MQTT
使用MQTT
MQTT设置
+ 已断开连接
+ 已连接
+ 连接测试
启用MQTT
地址
用户名
@@ -1114,4 +1114,7 @@
备注
连接
完成
+ 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 030532869..20ee6c639 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,9 +41,11 @@
內部傳輸
通過喜好
僅顯示已忽略的節點
+ 排除 MQTT
無法識別
正在等待確認
發送佇列中
+ 已傳送至 Mesh
不明
透過 SF++ 鏈路由…
已在 SF++ 鏈上確認
@@ -120,6 +123,7 @@
嘗試獲取 GPS 位置的頻率(< 10 秒將保持 GPS 模組開啟)。
位置訊息可選的附加欄位。包含的欄位越多,訊息越大,將造成空中時間拉長和封包遺失的風險增加。
將盡可能使所有元件進入睡眠狀態。對於 tracker 和 sensor 角色,此模式將包含 LoRa 無線電。如果您想搭配手機應用程式使用設備,或正在使用沒有使用者按鈕的設備,請勿啟用此設定。
+ 從您的私鑰生成並傳送給網狀網路中的其他節點,以供它們計算出共享密鑰。
用於與遠端設備交換密鑰。
被授權可對此節點發送管理訊息的公鑰。
設備處於受管理狀態,使用者無法變更任何設備設定。
@@ -168,12 +172,20 @@
正在連線
未連線
未選擇裝置
+ 未知的裝置
+ 找不到網路裝置
+ 找不到 USB 裝置
+ USB
+ 展示模式
已連接裝置,但該裝置正在休眠中
需要應用程式更新
您必須在應用商店(或 Github)更新此應用程式。它太舊無法與此無線電韌體通訊。請閱讀我們關於此主題的文件。
無(停用)
服務通知
致謝
+ 開放原始碼函式庫
+ Meshtastic 採用以下開源函式庫建構而成。點擊任一函式庫以查看其授權條款。
+ %1$d 函式庫
此頻道 URL 無效,無法使用
偵錯面板
解析封包:
@@ -204,7 +216,19 @@
符合全部條件
這將完全移除裝置上的所有日誌封包與資料庫記錄 - 這是一個完整的重設,且無法復原。
清除
+ 搜尋表情符號……
+ 更多符號
頻道
+ %1$s: %2$s
+ 來自 %1$s 的訊息:%2$s
+ 標頭
+ 標尾
+ 點形
+ 文字
+ 儀表板
+ 梯度
+ 這是一個一個一個可客製化的組合元件
+ 還支援多行文字與多種樣式
訊息傳遞狀態
下方有新的訊息
私訊通知
@@ -225,10 +249,13 @@
恢復預設設置
套用
主題
+ 對比度
淺色
深色
系統預設
選擇主題
+ 對比度等級
+ 標準
中等
高
將手機位置提供給Mesh網路
@@ -316,6 +343,8 @@
目前:
永久靜音
未靜音
+ 已靜音 %1$d 天 %2$s 小時
+ 已靜音 %1$s 小時
將「%1$s」的通知設為靜音?
取消「%1$s」的通知靜音?
替換
@@ -325,7 +354,10 @@
電池
頻道利用率
空中時間使用率
+ %1$s:%2$s%%
+ %1$s:%2$s%V
%1$s
+ %1$s:%2$s
溫度
濕度
土壤溫度
@@ -346,12 +378,9 @@
使用者資訊
新節點通知
SNR
- 信噪比(SNR),用於通訊中量化所需信號與背景噪音水平的指標。在 Meshtastic 及其他無線系統中,信噪比越高表示信號越清晰,可以提高數據傳輸的可靠性和品質。
RSSI
- 接收信號強度指示(RSSI)用於測量天線所接收到信號的功率強度。 RSSI 值越高通常代表連線越強且穩定。
(室內空氣品質) 相對尺度 IAQ 值,由 Bosch BME680 測量。值範圍 0–500。
裝置計量資料
- 節點地圖
位置
最後位置更新
環境計量資料
@@ -379,12 +408,26 @@
持續時間:%1$s 秒
追蹤至目的地的路由:\n\n
追蹤回到本機的路由:\n\n
+ 去程跳數
+ 回程跳數
+ 來回跳數
+ 無回應
+ 1分鐘負載
+ 5分鐘負載
+ 15分鐘負載
+ 1分鐘系統負載平均值
+ 5分鐘系統負載平均值
+ 15分鐘系統負載平均值
+ 可用系統記憶體(位元組)
1小時
二十四小時
一週
二週
1個月
最大值
+ 最小
+ 展開圖表
+ 收起圖表
未知年齡
複製
警鈴字符!
@@ -398,11 +441,17 @@
頻道1
頻道2
頻道3
+ 頻道 4
+ 頻道 5
+ 頻道 6
+ 頻道 7
+ 頻道 8
當前
電壓
你確定嗎?
設備角色檔案和關於選擇正確的設備角色的博客文章 。]]>
我知道我在做什麼。
+ 節點 %1$s 電量過低 (%2$d%)
低電量通知
低電量:%1$s
低電量通知(收藏節點)
@@ -528,6 +577,9 @@
輸出持續時間(毫秒)
通知逾時時間(秒)
鈴聲
+ 已匯入鈴聲
+ 檔案為空
+ 匯入錯誤:%1$s
播放
使用 I2S 控制蜂鳴器
LoRa
@@ -551,6 +603,23 @@
無視MQTT
允許轉發至 MQTT
MQTT配置
+ 已停用
+ 已中斷連線
+ 已斷線 — %1$s
+ 正在連接…
+ 已連線
+ 重新連接中…
+ 重新連接中(第 %1$d 次嘗試) — %2$s
+ 測試連線
+ 正在查詢 Broker…
+ 可供連線,Broker 已驗證並接受憑證。
+ 可供連線(%1$s)
+ Broker 遭拒:%1$s
+ 找不到伺服器
+ 無法連線至 Broker 中繼伺服器(TCP)
+ TLS 握手失敗
+ 經過 %1$d 毫秒後逾時
+ 測試失敗
啟用MQTT服務器
地址
用戶名
@@ -622,6 +691,8 @@
啟用序列埠
啟用 Echo
序列埠鮑率
+ RX
+ TX
逾時
序列埠模式
覆蓋控制台序列埠
@@ -656,8 +727,15 @@
距離
照度
風速
+ 風速
+ 陣風
+ 風停
+ 風向
+ 降雨(1h)
+ 降雨(24h)
重量
輻射
+ 1-Wire 溫度
室內空氣品質 (IAQ)
網址
@@ -674,6 +752,7 @@
時間戳記
航向
速度
+ %1$d Km/h
衛星數
海拔
頻率
@@ -739,6 +818,11 @@
顯示路徑
顯示定位精準度
客户端通知
+ 金鑰驗證
+ 金鑰驗證請求
+ 金鑰驗證已完成
+ 偵測到重複的公鑰
+ 偵測到加密金鑰強度不足
偵測到金鑰已洩漏,點選確定後重新產生金鑰。
重新產生私鑰
您確定要重新產生密鑰嗎?\n\n連線過的其他節點需要刪除並重新交換金鑰後才能恢復加密通訊連線。
@@ -791,7 +875,14 @@
請輸入訊息
PAX 人流計量
PAX
+ PAX: %1$d
+ B:%1$d
+ W:%1$d
+ PAX: %1$s
+ BLE: %1$s
+ WiFi: %1$s
無可用的 PAX 人流計量資料。
+ mPWRD-OS 的 Wi-Fi 設定
藍牙裝置
連接裝置
超過速率限制,請稍後再嘗試。
@@ -912,6 +1003,7 @@
穩定版
Alpha 測試版
注意:更新期間將會暫時中斷您的裝置連線。
+ 正在下載韌體... %1$d%
錯誤: %1$s
重試
更新成功!
@@ -954,6 +1046,7 @@
版本說明
未知錯誤
缺少節點使用者資訊。
+ 電量過低 (%1$d%%),請在更新前為您的裝置充電。
無法取得韌體檔案。
USB 更新失敗
韌體雜湊值遭拒。裝置可能需要雜湊值配置或開機載入程式更新。
@@ -1027,8 +1120,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 +1142,8 @@
新增本機 MBTiles 檔案
TAK (ATAK)
TAK 設定
+ 啓用本地 TAK 伺服器
+ 在 8089 埠啟動一個用於 ATAK 連線的 TCP 伺服器
隊伍顏色
隊員角色
未指定
@@ -1090,6 +1187,46 @@
僅本地定位資訊(中繼)
保留路由跳數
注意
+ 裝置儲存空間與使用者介面(唯讀)
+ 主題 %1$s,語言 %2$s
+ 可使用檔案(%1$d):
+ - %1$s(%2$d 位元)
+ 未發現任何檔案。
連線
完成
+ mPWRD-OS 的 Wi-Fi 設定
+ 透過藍牙為您的 mPWRD-OS 裝置設定 Wi-Fi 憑證。
+ 進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS
+ 正在搜尋裝置…
+ 找到裝置
+ 準備好掃描 Wi-Fi 網路了。
+ 搜尋網路
+ 正在搜尋…
+ 正在套用 Wi-Fi 設定…
+ 找不到網路
+ 無法連接:%1$s
+ 無法搜尋到 Wi-Fi 網路:%1$s
+ %1$d%
+ 可用的網路
+ 網路名稱(SSID)
+ 手動輸入或選擇一個網路
+ Wi-Fi 已設定完成!
+ 無法套用 Wi-Fi 設定
+ Meshtastic Desktop
+ 顯示 Meshtastic
+ 離開
+ Meshtastic
+ 匯出 TAK 資料封包
+ 清除時區
+ 過濾器
+ 移除篩選條件
+ 顯示空氣品質圖例
+ 顯示訊息狀態
+ 傳送回覆
+ 複製訊息
+ 選擇訊息
+ 刪除訊息
+ 使用表情符號回應
+ 選擇裝置
+ 選擇網路
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index a958ce1ee..505d80821 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
@@ -638,6 +634,23 @@
Ignore MQTT
Ok to MQTT
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
@@ -1261,4 +1274,22 @@
Enter or select a network
WiFi configured successfully!
Failed to apply WiFi configuration
+ Meshtastic Desktop
+ Show Meshtastic
+ Quit
+ Meshtastic
+ Export TAK Data Package
+ mPWRD-OS
+ Clear time zone
+ 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/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/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/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/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