diff --git a/.github/instructions/kmp-common.instructions.md b/.github/instructions/kmp-common.instructions.md
index 235d5826d..7dac915bc 100644
--- a/.github/instructions/kmp-common.instructions.md
+++ b/.github/instructions/kmp-common.instructions.md
@@ -14,4 +14,7 @@ applyTo: "**/commonMain/**/*.kt"
- Never use plain `androidx.compose` dependencies in `commonMain`.
- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings.
- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`.
+- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls.
- Check `gradle/libs.versions.toml` before adding dependencies.
+- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code.
+- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`.
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 e08e3d2f3..1faa1a4ad 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -49,236 +49,24 @@
"automerge": true
},
{
+ "description": "Meshtastic Protobufs changelog link",
"matchPackageNames": [
"https://github.com/meshtastic/protobufs.git"
],
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
- "groupName": "Meshtastic Protobufs",
- "groupSlug": "meshtastic-protobufs",
"automerge": true
},
{
- "description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)",
- "groupName": "AndroidX (General)",
- "groupSlug": "androidx-general",
+ "description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
+ "groupName": "compose-multiplatform",
"matchPackageNames": [
- "/^androidx\\./",
- "!/^androidx\\.room/",
- "!/^androidx\\.lifecycle/",
- "!/^androidx\\.navigation/",
- "!/^androidx\\.datastore/",
- "!/^androidx\\.compose\\.material3\\.adaptive/",
- "!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/",
- "!/^androidx\\.test\\.espresso/",
- "!/^androidx\\.test\\.ext/",
- "!/^androidx\\.hilt/"
+ "/^org\\.jetbrains\\.compose/",
+ "androidx.compose.runtime:runtime-tracing",
+ "androidx.compose.ui:ui-test-manifest"
]
},
{
- "description": "Group JetBrains Compose Multiplatform plugin and libraries (separate versioning from AndroidX Compose)",
- "groupName": "Compose Multiplatform (JetBrains)",
- "groupSlug": "compose-multiplatform",
- "matchPackageNames": [
- "/^org\\.jetbrains\\.compose/"
- ]
- },
- {
- "description": "Group Kotlin standard library, coroutines, and serialization",
- "groupName": "Kotlin Ecosystem",
- "groupSlug": "kotlin",
- "matchPackageNames": [
- "/^org\\.jetbrains\\.kotlin/",
- "/^org\\.jetbrains\\.kotlinx/"
- ]
- },
- {
- "description": "Group Dagger and Hilt dependencies",
- "groupName": "Dagger & Hilt",
- "groupSlug": "hilt",
- "matchPackageNames": [
- "/^com\\.google\\.dagger/",
- "/^androidx\\.hilt/"
- ]
- },
- {
- "description": "Group Accompanist libraries",
- "groupName": "Accompanist",
- "groupSlug": "accompanist",
- "matchPackageNames": [
- "/^com\\.google\\.accompanist/"
- ]
- },
- {
- "description": "Group JVM testing libraries (JUnit, Mockito, Robolectric)",
- "groupName": "JVM Testing Libraries",
- "groupSlug": "jvm-testing",
- "matchPackageNames": [
- "/^junit:junit$/",
- "/^org\\.mockito:/",
- "/^org\\.robolectric:robolectric$/"
- ],
- "automerge": true
- },
- {
- "description": "Group AndroidX Testing libraries",
- "groupName": "AndroidX Testing",
- "groupSlug": "androidx-testing",
- "matchPackageNames": [
- "/^androidx\\.test\\.espresso/",
- "/^androidx\\.test\\.ext/"
- ],
- "automerge": true
- },
- {
- "description": "Group Static Analysis tools (Detekt, Spotless)",
- "groupName": "Static Analysis",
- "groupSlug": "static-analysis",
- "matchPackageNames": [
- "/^io\\.gitlab\\.arturbosch\\.detekt/",
- "/^io\\.nlopez\\.compose\\.rules/",
- "/^com\\.diffplug\\.spotless/"
- ],
- "automerge": true
- },
- {
- "description": "Group Square networking libraries (OkHttp, Retrofit)",
- "groupName": "Square Networking",
- "groupSlug": "square-network",
- "matchPackageNames": [
- "/^com\\.squareup\\.okhttp3/",
- "/^com\\.squareup\\.retrofit2/"
- ],
- "automerge": true
- },
- {
- "description": "Group Coil image loading library",
- "groupName": "Coil",
- "groupSlug": "coil",
- "matchPackageNames": [
- "/^io\\.coil-kt\\.coil3/"
- ],
- "automerge": true
- },
- {
- "description": "Group ZXing barcode scanning libraries",
- "groupName": "ZXing",
- "groupSlug": "zxing",
- "matchPackageNames": [
- "/^com\\.journeyapps:zxing-android-embedded/",
- "/^com\\.google\\.zxing:core/"
- ],
- "automerge": true
- },
- {
- "description": "Group Eclipse Paho MQTT client libraries",
- "groupName": "MQTT Paho Client",
- "groupSlug": "mqtt-paho",
- "matchPackageNames": [
- "/^org\\.eclipse\\.paho/"
- ],
- "automerge": true
- },
- {
- "description": "Group Mike Penz Markdown renderer libraries",
- "groupName": "Markdown Renderer (Mike Penz)",
- "groupSlug": "markdown-renderer-mikepenz",
- "matchPackageNames": [
- "/^com\\.mikepenz/"
- ],
- "automerge": true
- },
- {
- "description": "Group Firebase libraries",
- "groupName": "Firebase",
- "groupSlug": "firebase",
- "matchPackageNames": [
- "/^com\\.google\\.firebase/"
- ],
- "automerge": true
- },
- {
- "description": "Group Datadog libraries",
- "groupName": "Datadog",
- "groupSlug": "datadog",
- "matchPackageNames": [
- "/^com\\.datadoghq/"
- ],
- "automerge": true
- },
- {
- "description": "Group OpenStreetMap (OSM) libraries",
- "groupName": "OSM Libraries",
- "groupSlug": "osm-libraries",
- "matchPackageNames": [
- "/^org\\.osmdroid/",
- "/^com\\.github\\.MKergall\\.osmbonuspack/",
- "/^mil\\.nga/"
- ],
- "automerge": true
- },
- {
- "description": "Group Google Maps Compose libraries",
- "groupName": "Google Maps Compose",
- "groupSlug": "google-maps-compose",
- "matchPackageNames": [
- "/^com\\.google\\.android\\.gms:play-services-location/",
- "/^com\\.google\\.maps\\.android/"
- ],
- "automerge": true
- },
- {
- "description": "Group Google Protobuf runtime libraries",
- "groupName": "Protobuf Runtime",
- "groupSlug": "protobuf-runtime",
- "matchPackageNames": [
- "/^com\\.google\\.protobuf/",
- "!https://github.com/meshtastic/protobufs.git"
- ]
- },
- {
- "description": "Group AndroidX Room libraries",
- "groupName": "AndroidX Room",
- "groupSlug": "androidx-room",
- "matchPackageNames": [
- "/^androidx\\.room/"
- ],
- "automerge": true
- },
- {
- "description": "Group AndroidX Lifecycle libraries",
- "groupName": "AndroidX Lifecycle",
- "groupSlug": "androidx-lifecycle",
- "matchPackageNames": [
- "/^androidx\\.lifecycle/"
- ]
- },
- {
- "description": "Group AndroidX Navigation libraries",
- "groupName": "AndroidX Navigation",
- "groupSlug": "androidx-navigation",
- "matchPackageNames": [
- "/^androidx\\.navigation/"
- ]
- },
- {
- "description": "Group AndroidX DataStore libraries",
- "groupName": "AndroidX DataStore",
- "groupSlug": "androidx-datastore",
- "matchPackageNames": [
- "/^androidx\\.datastore/"
- ]
- },
- {
- "description": "Group AndroidX Adaptive UI libraries",
- "groupName": "AndroidX Adaptive UI",
- "groupSlug": "androidx-adaptive-ui",
- "matchPackageNames": [
- "/^androidx\\.compose\\.material3\\.adaptive/",
- "/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/"
- ]
- },
- {
- "description": "Restrict sensitive infrastructure to patch updates only (manual minor)",
+ "description": "Restrict sensitive infrastructure to manual minor updates",
"matchUpdateTypes": [
"minor"
],
@@ -305,4 +93,4 @@
"automerge": false
}
]
-}
\ No newline at end of file
+}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index faa9ff3c3..f7c8151c7 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -66,7 +66,7 @@ jobs:
run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact
- uses: actions/upload-pages-artifact@v4
+ uses: actions/upload-pages-artifact@v5
with:
path: build/dokka/html
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/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 22a611576..d450711ce 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -3,10 +3,6 @@ name: Pull Request CI
on:
pull_request:
branches: [ main ]
- paths-ignore:
- - '**/*.md'
- - 'docs/**'
- - '.gitignore'
permissions:
contents: read
@@ -74,8 +70,7 @@ jobs:
}
allowed_extra_roots = {'baselineprofile'}
- excluded_roots = {'mesh_service_example'}
- expected_roots = (module_roots | allowed_extra_roots) - excluded_roots
+ expected_roots = module_roots | allowed_extra_roots
filter_paths = {
path.split('/')[0]
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 26dbe7685..632bf1ea4 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -213,7 +213,7 @@ jobs:
files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov
- if: ${{ !cancelled() }}
+ if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
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/code-review/SKILL.md b/.skills/code-review/SKILL.md
index dce08761d..acab253d5 100644
--- a/.skills/code-review/SKILL.md
+++ b/.skills/code-review/SKILL.md
@@ -1,16 +1,7 @@
# Skill: Code Review
## Description
-Perform comprehensive and precise code reviews for the `Meshtastic-Android` project. This skill ensures that incoming changes adhere strictly to the project's architecture guidelines, Kotlin Multiplatform (KMP) conventions, Modern Android Development (MAD) standards, and Jetpack Compose Multiplatform (CMP) best practices.
-
-## Context & Prerequisites
-The `Meshtastic-Android` codebase is a highly modernized Kotlin Multiplatform (KMP) application designed for off-grid, decentralized mesh networks.
-- **Language:** Kotlin (primary), JDK 21 required.
-- **Architecture:** KMP core with Android and Desktop host shells.
-- **UI:** Jetpack Compose Multiplatform (CMP) and Material 3 Adaptive.
-- **Navigation:** JetBrains Navigation 3 (Scene-based).
-- **DI:** Koin Annotations (with K2 compiler plugin).
-- **Async & I/O:** Kotlin Coroutines, Flow, Okio, Ktor.
+Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
## Code Review Checklist
@@ -22,14 +13,15 @@ When reviewing code, meticulously verify the following categories. Flag any devi
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
- `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
- - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` or `expect`/`actual`
+ - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`)
+- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`).
- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`.
- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target.
- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.
### 2. UI & Compose Multiplatform (CMP)
- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
-- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`.
+- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).
- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
@@ -45,8 +37,10 @@ When reviewing code, meticulously verify the following categories. Flag any devi
### 5. Networking, DB & I/O
- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp.
+- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths.
- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers.
- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`.
+- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead.
- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions.
### 6. Dependency Catalog Aliases
@@ -56,11 +50,15 @@ When reviewing code, meticulously verify the following categories. Flag any devi
- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
### 7. Testing
-- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
+- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` from `androidx.compose.ui.test.v2` (not the deprecated v1 `androidx.compose.ui.test` package) + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`.
- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking.
- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
+### 8. ProGuard / R8 Rules
+- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned.
+- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed.
+
## Review Output Guidelines
1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.
2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio").
diff --git a/.skills/compose-ui/SKILL.md b/.skills/compose-ui/SKILL.md
index d2e79c542..22fe1b489 100644
--- a/.skills/compose-ui/SKILL.md
+++ b/.skills/compose-ui/SKILL.md
@@ -14,8 +14,31 @@ Guidelines for building shared UI, adaptive layouts, and handling strings/resour
- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings.
- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context.
- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer).
- - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`).
+ - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`):
+ ```kotlin
+ val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
+ stringResource(Res.string.battery_percent, formatted) // uses %1$s
+ ```
- **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings.
+
+### String Formatting Decision Tree
+Choose the right tool for the job:
+
+| Scenario | Tool | Example |
+|----------|------|---------|
+| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)` → `"77.0°F"` |
+| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` |
+| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` |
+| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` |
+| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` |
+| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` |
+
+**Rules:**
+1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats.
+2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls.
+3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`.
+4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision.
+
- **Workflow to Add a String:**
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
2. Use the generated `org.meshtastic.core.resources.` symbol.
@@ -25,6 +48,13 @@ Guidelines for building shared UI, adaptive layouts, and handling strings/resour
- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code.
+## 4. Compose Previews
+- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables.
+- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated.
+
+## 5. Dialog & State Patterns
+- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed.
+
## Reference Anchors
- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
diff --git a/.skills/implement-feature/SKILL.md b/.skills/implement-feature/SKILL.md
index 1efa3caa0..0277bee10 100644
--- a/.skills/implement-feature/SKILL.md
+++ b/.skills/implement-feature/SKILL.md
@@ -33,5 +33,9 @@ A step-by-step workflow for implementing a new feature in the Meshtastic-Android
### 6. Verify Locally
- Run the baseline checks (see `testing-ci` skill):
```bash
- ./gradlew spotlessCheck detekt assembleDebug test allTests
+ ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
+ ```
+- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds:
+ ```bash
+ ./gradlew assembleFdroidRelease :desktop:runRelease
```
diff --git a/.skills/kmp-architecture/SKILL.md b/.skills/kmp-architecture/SKILL.md
index 805d9f2f9..46602c430 100644
--- a/.skills/kmp-architecture/SKILL.md
+++ b/.skills/kmp-architecture/SKILL.md
@@ -16,12 +16,14 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper.
## 3. Core Libraries & Constraints
-- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly.
+- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic.
+- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops).
- **Standard Library Replacements:**
- `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging.
+- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL.
- **BLE:** Route through `core:ble` using **Kable**.
- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
@@ -38,6 +40,10 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
## 6. I/O & Serialization
- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
+- **Room Patterns:**
+ - Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic.
+ - Use `LIMIT 1` on `@Query` methods that expect a single row.
+ - Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit).
## 7. Build-Logic Conventions
- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md
index 557db4717..c9d7336a6 100644
--- a/.skills/navigation-and-di/SKILL.md
+++ b/.skills/navigation-and-di/SKILL.md
@@ -12,9 +12,27 @@ 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 canonical startup uses the plugin's typed `startKoin()` stub, which the plugin transforms at compile time via IR:
+```kotlin
+// Bootstrap class — separate from @Module, references the root module graph
+@KoinApplication(modules = [AppKoinModule::class])
+object AndroidKoinApp
+
+// In Application.onCreate()
+startKoin {
+ androidContext(this@MeshUtilApplication)
+ workManagerFactory()
+}
+```
+- `@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
### Guidelines
@@ -32,6 +50,7 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
## 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/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md
index 6df668bf2..2224fa7ad 100644
--- a/.skills/project-overview/SKILL.md
+++ b/.skills/project-overview/SKILL.md
@@ -1,21 +1,13 @@
# Skill: Project Overview & Codebase Map
## Description
-High-level project context, module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
+Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
-## 1. Project Vision & Architecture
-Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling expansion to iOS and Desktop while maintaining a high-performance native Android experience.
+- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26.
+- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
+- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
-- **Language:** Kotlin (primary), AIDL.
-- **Build System:** Gradle (Kotlin DSL). JDK 21 is REQUIRED.
-- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
-- **Flavors:**
- - `fdroid`: Open source only, no tracking/analytics.
- - `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production").
-- **KMP Modules:** Most `core:*` modules declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
-- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
-
-## 2. Codebase Map
+## Codebase Map
| Directory | Description |
| :--- | :--- |
@@ -47,13 +39,12 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. |
| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. |
-| `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app. See `core/api/README.md` for the current integration guide. |
-## 3. Namespacing
+## Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
-## 4. Environment Setup
+## Environment Setup
1. **JDK 21 MUST be used** to prevent Gradle sync/build failures.
2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`:
```properties
@@ -62,7 +53,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
datadogClientToken=dummy_token
```
-## 5. Workspace Bootstrap (MUST run before any build)
+## Workspace Bootstrap (MUST run before any build)
Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you.
1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it:
@@ -81,17 +72,12 @@ Agents **MUST** perform these steps automatically at the start of every session
git submodule update --init
```
-## 6. Troubleshooting
-- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
-- **Missing Secrets:** Check `local.properties` (see Environment Setup above).
-- **JDK Version:** JDK 21 is required.
-- **SDK location not found:** See Workspace Bootstrap step 1 above.
-- **Proto generation failures:** See Workspace Bootstrap step 2 above.
-- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
-- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`).
+3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
+ ```bash
+ [ -f local.properties ] || cp secrets.defaults.properties local.properties
+ ```
-## Reference Anchors
-- **KMP Migration Status:** `docs/kmp-status.md`
-- **Roadmap:** `docs/roadmap.md`
-- **Architecture Decision Records:** `docs/decisions/`
-- **Version Catalog:** `gradle/libs.versions.toml`
+## Troubleshooting
+- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
+- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
+- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`.
diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md
index 586c1ef9c..1c8b7b901 100644
--- a/.skills/testing-ci/SKILL.md
+++ b/.skills/testing-ci/SKILL.md
@@ -5,17 +5,14 @@ Guidelines and commands for verifying code changes locally and understanding the
## 1) Baseline local verification order
-Run in this order for routine changes to ensure code formatting, analysis, and basic compilation:
+Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
```bash
-./gradlew clean
-./gradlew spotlessCheck
-./gradlew spotlessApply
-./gradlew detekt
-./gradlew assembleDebug
-./gradlew test allTests
+./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
+> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues.
+
> **Why `test allTests` and not just `test`:**
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
@@ -51,7 +48,7 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p
2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`):
- `shard-core`: `allTests` for all `core:*` KMP modules.
- `shard-feature`: `allTests` for all `feature:*` KMP modules.
- - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`).
+ - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`).
Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
@@ -86,11 +83,3 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p
- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.).
- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone.
-## 5) Shell & Tooling Conventions
-- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt.
-- **Text Search:** Prefer `rg` (ripgrep) over `grep` or `find` for fast text searching across the codebase.
-
-## 6) Agent/Developer Guidance
-- Start with the smallest set that validates your touched area.
-- If unable to run full validation locally, report exactly what ran and what remains.
-- Keep documentation synced in `AGENTS.md` and `.skills/` directories.
diff --git a/AGENTS.md b/AGENTS.md
index 73d29f2b9..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.
@@ -25,11 +26,16 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes
- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically:
1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user.
2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds.
+ 3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails.
- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing.
- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code.
- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first).
- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing:
- `./gradlew clean spotlessCheck spotlessApply detekt assembleDebug test allTests`
+ ```
+ ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
+ ```
+ > **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required.
+ > For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job).
@@ -52,13 +58,51 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes
- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions.
- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly.
-Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md` as needed.
+Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed.
- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application.
-- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`.
-- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety.
-- **CMP Over Android:** Use `compose-multiplatform` constraints (e.g., no float formatting in `stringResource`).
+- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly.
+- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated.
+- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code.
+- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds.
- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist.
+- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution.
+- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them.
+- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules.
+- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change.
+
+
+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.
+- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters.
+
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/SOUL.md b/SOUL.md
deleted file mode 100644
index 45924b40f..000000000
--- a/SOUL.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Meshtastic-Android: AI Agent Soul (SOUL.md)
-
-This file defines the personality, values, and behavioral framework of the AI agent for this repository.
-
-## 1. Core Identity
-I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-Android codebase while maintaining its integrity as a secure, decentralized communication tool. I am not just a "helpful assistant"; I am a senior peer programmer who takes ownership of the technical stack.
-
-## 2. Core Truths & Values
-- **Privacy is Paramount:** Meshtastic is used for off-grid, often sensitive communication. I treat user data, location info, and cryptographic keys with extreme caution. I will never suggest logging PII or secrets.
-- **Code is a Liability:** I prefer simple, readable code over clever abstractions. I remove dead code and minimize dependencies wherever possible.
-- **Decentralization First:** I prioritize architectural patterns that support offline-first and peer-to-peer logic.
-- **MAD & KMP are the Standard:** Modern Android Development (Compose, Koin, Coroutines) and Kotlin Multiplatform are not suggestions; they are the foundation. I resist introducing legacy patterns unless absolutely required for OS compatibility.
-
-## 3. Communication Style (The "Vibe")
-- **Direct & Concise:** I skip the fluff. I provide technical rationale first.
-- **Opinionated but Grounded:** I provide clear technical recommendations based on established project conventions.
-- **Action-Oriented:** I don't just "talk" about code; I implement, test, and format it.
-
-## 4. Operational Boundaries
-- **Zero Lint Tolerance (for code changes):** I consider a coding task incomplete if `detekt` fails or `spotlessCheck` is not passing for touched modules.
-- **Test-Driven Execution (where feasible):** For bug fixes, I should reproduce the issue with a test before fixing it when practical. For new features, I should add appropriate verification logic.
-- **Dependency Discipline:** I never add a library without checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity.
-- **No Hardcoded Strings:** I will refuse to add hardcoded UI strings, strictly adhering to the `:core:resources` KMP resource system.
-
-## 5. Evolution
-I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers.
-
-For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth.
-For implementation recipes and verification scope, I use `.skills/` directory.
-
-
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0942756c0..d239d0530 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -171,8 +171,6 @@ configure {
} else {
signingConfig = signingConfigs.getByName("debug")
}
- isMinifyEnabled = true
- isShrinkResources = true
isDebuggable = false
}
}
@@ -266,7 +264,6 @@ dependencies {
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android)
- implementation(libs.koin.androidx.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)
@@ -282,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 995f659ba..de2b3144c 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,61 +1,45 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
+# ============================================================================
+# Meshtastic Android — ProGuard / R8 rules for release minification
+# ============================================================================
+# Open-source project: obfuscation and optimization are disabled. We rely on
+# tree-shaking (unused code removal) for APK size reduction.
#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
+# 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.
+# ============================================================================
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
+# ---- General ----------------------------------------------------------------
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
--keepattributes SourceFile,LineNumberTable
+# Open-source — no need to obfuscate
+-dontobfuscate
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
+# 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
-# Room KMP: preserve generated database constructor (required for R8/ProGuard)
--keep class * extends androidx.room.RoomDatabase { (); }
+# 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
-# Needed for protobufs
--keep class com.google.protobuf.** { *; }
--keep class org.meshtastic.proto.** { *; }
+# ---- Networking (transitive references from Ktor on Android) ----------------
-# Networking
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-# ?
--dontwarn java.lang.reflect.**
--dontwarn com.google.errorprone.annotations.**
-
-# Our app is opensource no need to obsfucate
--dontobfuscate
--optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
-
-# 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.** { *; }
-
-# R8 optimization for Kotlin null checks (AGP 9.0+)
--processkotlinnullchecks remove
-
-# Compose Multiplatform resources: keep the resource library internals and generated Res
-# accessor classes so R8 does not tree-shake the resource loading infrastructure.
-# Without these rules the fdroid flavor (which has fewer transitive Compose dependencies
-# than google) crashes at startup with a misleading URLDecodeException due to R8
-# exception-class merging (see Koin keep rule above).
--keep class org.jetbrains.compose.resources.** { *; }
--keep class org.meshtastic.core.resources.** { *; }
-
-# Nordic BLE
--dontwarn no.nordicsemi.kotlin.ble.environment.android.mock.**
--keep class no.nordicsemi.kotlin.ble.environment.android.mock.** { *; }
--keep class no.nordicsemi.kotlin.ble.environment.android.compose.** { *; }
+# 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/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 70ff4858d..e4eabbb76 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -28,7 +28,11 @@ import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
-import kotlinx.coroutines.Dispatchers
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.http.isSuccess
+import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -45,6 +49,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -77,6 +82,8 @@ data class MapCameraPosition(
@KoinViewModel
class MapViewModel(
private val application: Application,
+ private val dispatchers: CoroutineDispatchers,
+ private val httpClient: HttpClient,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
@@ -404,7 +411,7 @@ class MapViewModel(
}
private fun loadPersistedLayers() {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
@@ -412,32 +419,33 @@ class MapViewModel(
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
- val loadedItems = persistedLayerFiles.mapNotNull { file ->
- if (file.isFile) {
- val layerType =
- when (file.extension.lowercase()) {
- "kml",
- "kmz",
- -> LayerType.KML
- "geojson",
- "json",
- -> LayerType.GEOJSON
- else -> null
- }
+ val loadedItems =
+ persistedLayerFiles.mapNotNull { file ->
+ if (file.isFile) {
+ val layerType =
+ when (file.extension.lowercase()) {
+ "kml",
+ "kmz",
+ -> LayerType.KML
+ "geojson",
+ "json",
+ -> LayerType.GEOJSON
+ else -> null
+ }
- layerType?.let {
- val uri = Uri.fromFile(file)
- MapLayerItem(
- name = file.nameWithoutExtension,
- uri = uri,
- isVisible = !hiddenLayerUrls.contains(uri.toString()),
- layerType = it,
- )
+ layerType?.let {
+ val uri = Uri.fromFile(file)
+ MapLayerItem(
+ name = file.nameWithoutExtension,
+ uri = uri,
+ isVisible = !hiddenLayerUrls.contains(uri.toString()),
+ layerType = it,
+ )
+ }
+ } else {
+ null
}
- } else {
- null
}
- }
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
@@ -550,7 +558,7 @@ class MapViewModel(
}
}
- private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
+ private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers")
@@ -621,7 +629,7 @@ class MapViewModel(
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
- withContext(Dispatchers.IO) {
+ withContext(dispatchers.io) {
try {
val file = uri.toFile()
if (file.exists()) {
@@ -636,11 +644,15 @@ class MapViewModel(
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
- return withContext(Dispatchers.IO) {
+ return withContext(dispatchers.io) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
- val url = java.net.URL(uriToLoad.toString())
- java.io.BufferedInputStream(url.openStream())
+ val response = httpClient.get(uriToLoad.toString())
+ if (!response.status.isSuccess()) {
+ Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
+ return@withContext null
+ }
+ response.bodyAsChannel().toInputStream()
} else {
application.contentResolver.openInputStream(uriToLoad)
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
index e33fb1f8c..668dedbaa 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
@@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
@Module
@ComponentScan("org.meshtastic.app.map")
@@ -36,9 +36,10 @@ class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
- fun provideGoogleMapsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create(
- migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
- scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
- produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
- )
+ fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
+ scope = CoroutineScope(dispatchers.io + SupervisorJob()),
+ produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
+ )
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 43468c69d..f7d2ce900 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -288,7 +288,7 @@
+ android:resource="@xml/widget_local_stats_info" />
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 4859e45cf..ffdb465d6 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -24,6 +24,13 @@
}
],
"alpha": [
+ {
+ "id": "v2.7.22.96dd647",
+ "title": "Meshtastic Firmware 2.7.22.96dd647 Alpha",
+ "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.22.96dd647",
+ "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.22.96dd647/firmware-2.7.22.96dd647.json",
+ "release_notes": "## 🐛 Bug fixes and maintenance\r\n\r\n- Fix(native): implement BinarySemaphorePosix with proper pthread synchronization by @iannucci in https://github.com/meshtastic/firmware/pull/9895\r\n- Meshtasticd: Add configs for ebyte-ecb41-pge (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10086\r\n- Meshtasticd: Add configs for forlinx-ok3506-s12 (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10087\r\n- Fix Linux Input enable logic by @jp-bennett in https://github.com/meshtastic/firmware/pull/10093\r\n- PPA: Use SFTP method for uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/10138\r\n- Switch PlatformIO deps from PIO Registry to tagged GitHub zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/10142\r\n- Fix display method to use const qualifier for previousBuffer pointer by @vidplace7 in https://github.com/meshtastic/firmware/pull/10146\r\n- Fix last cppcheck issue by @caveman99 in https://github.com/meshtastic/firmware/pull/10154\r\n- Fix heap blowout on TBeams by @thebentern in https://github.com/meshtastic/firmware/pull/10155\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update meshtastic-esp32_https_server digest to 0c71f38 by @app/renovate in https://github.com/meshtastic/firmware/pull/10081\r\n- Update meshtastic-st7789 digest to 222554e by @app/renovate in https://github.com/meshtastic/firmware/pull/10121\r\n- Update actions/github-script action to v9 by @app/renovate in https://github.com/meshtastic/firmware/pull/10122\r\n- Update meshtastic-st7789 digest to 7228c49 by @app/renovate in https://github.com/meshtastic/firmware/pull/10131\r\n- Update pnpm/action-setup action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10132\r\n- Update meshtastic-st7789 digest to 4d957e7 by @app/renovate in https://github.com/meshtastic/firmware/pull/10134\r\n- Update meshtastic-st7789 digest to a787bee by @app/renovate in https://github.com/meshtastic/firmware/pull/10147\r\n- Update softprops/action-gh-release action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/10150\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.21.1370b23...v2.7.22.96dd647"
+ },
{
"id": "v2.7.21.1370b23",
"title": "Meshtastic Firmware 2.7.21.1370b23 Alpha",
@@ -177,13 +184,6 @@
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.8.ef9d0d7",
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.8.ef9d0d7/firmware-esp32-2.6.8.ef9d0d7.zip",
"release_notes": "## 🚀 Enhancements\r\n* 20948 compass support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6707\r\n* Update XIAO_NRF_KIT RXEN Pin definition by @NomDeTom in https://github.com/meshtastic/firmware/pull/6717\r\n* Add client notification before role based power saving (sleep) by @thebentern in https://github.com/meshtastic/firmware/pull/6759\r\n* Actions: Fix end to end tests by @vidplace7 in https://github.com/meshtastic/firmware/pull/6776\r\n* Add clarifying note about AHT20 also being included with AHT10 library by @NomDeTom in https://github.com/meshtastic/firmware/pull/6787\r\n* Only send nodes on want_config of 69421 by @thebentern in https://github.com/meshtastic/firmware/pull/6792\r\n* Add contact admin message (for QR code) by @thebentern in https://github.com/meshtastic/firmware/pull/6806\r\n* Crowpanel 4.3, 5.0, 7.0 support by @caveman99 in https://github.com/meshtastic/firmware/pull/6611\r\n* MQTT userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6802\r\n* Unmessagable implementation and defaults by @thebentern in https://github.com/meshtastic/firmware/pull/6811\r\n* Added new map report opt-in for compliance and limit map report (and default) to one hour by @thebentern in https://github.com/meshtastic/firmware/pull/6813\r\n* chore(deps): update meshtastic/device-ui digest to 35576e1 by @renovate in https://github.com/meshtastic/firmware/pull/6747\r\n\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Renovate: fix device-ui match (tiny fix) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6748\r\n* Add some no-nonsense coercion for self-reporting node values by @thebentern in https://github.com/meshtastic/firmware/pull/6793\r\n* Device-install.sh: detect t-eth-elite as s3 device by @chri2 in https://github.com/meshtastic/firmware/pull/6767\r\n* Fixes BUG #6243 by @Richard3366 in https://github.com/meshtastic/firmware/pull/6781\r\n* Update Seeed Solar Node by @rcarteraz in https://github.com/meshtastic/firmware/pull/6763\r\n* Protect T-Echo's touch button against phantom presses in OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6735\r\n* Don't run `test-native` for event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6749\r\n* Fix EVENT_MODE on mqttless targets by @vidplace7 in https://github.com/meshtastic/firmware/pull/6750\r\n* Fix event templates (names, PSKs) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6753\r\n* Add suppport for Quectel L80 by @fifieldt in https://github.com/meshtastic/firmware/pull/6803\r\n\r\n## New Contributors\r\n* @chri2 made their first contribution in https://github.com/meshtastic/firmware/pull/6767\r\n* @Richard3366 made their first contribution in https://github.com/meshtastic/firmware/pull/6781\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.7.2d6181f...v2.6.8.ef9d0d7"
- },
- {
- "id": "v2.6.7.2d6181f",
- "title": "Meshtastic Firmware 2.6.7.2d6181f Alpha",
- "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.7.2d6181f",
- "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.7.2d6181f/firmware-esp32-2.6.7.2d6181f.zip",
- "release_notes": "## 🚀 Enhancements\r\n* Step one of Linux Sensor support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6673\r\n* PMSA003I: add support for driving SET pin low while not actively taking a telemetry reading by @vogon in https://github.com/meshtastic/firmware/pull/6569\r\n* UDP-multicast: bump platform-native to fix UDP read of unitialized memory bug by @Jorropo in https://github.com/meshtastic/firmware/pull/6686\r\n* UDP-multicast: remove the thread from the multicast thread API by @Jorropo in https://github.com/meshtastic/firmware/pull/6685\r\n* Rate limit waypoints and alerts and increase to allow every 10 seconds instead of 5 by @thebentern in https://github.com/meshtastic/firmware/pull/6699\r\n* Restore InkHUD to defaults on factory reset by @todd-herbert in https://github.com/meshtastic/firmware/pull/6637\r\n* MUI: native frame buffer support by @mverch67 in https://github.com/meshtastic/firmware/pull/6703\r\n* Add PA1010D GPS support by @fmckeogh in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Fix: native runs 100% CPU in tft_task_handler() when deviceScreen is null by @jp-bennett in https://github.com/meshtastic/firmware/pull/6695\r\n* Lock SPI bus while in use by InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/6719\r\n* Update template for event userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6720\r\n* Renovate: Add changelogs for device-ui, cleanup by @vidplace7 in https://github.com/meshtastic/firmware/pull/6733\r\n* Update Bosch BSEC2 to v1.8.2610, BME68x to v1.2.40408 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6727\r\n\r\n## New Contributors\r\n* @vogon made their first contribution in https://github.com/meshtastic/firmware/pull/6569\r\n* @fmckeogh made their first contribution in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.6.54c1423...v2.6.7.2d6181f"
}
]
},
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 03549c0b3..628865010 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -45,11 +45,12 @@ import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
+import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
-import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
@@ -57,8 +58,8 @@ import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
-import org.meshtastic.core.common.util.toMeshtasticUri
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.
@@ -124,6 +127,8 @@ class MainActivity : ComponentActivity() {
setSingletonImageLoaderFactory { get() }
val theme by model.theme.collectAsStateWithLifecycle()
+ val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle()
+ val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue)
val dynamic = theme == MODE_DYNAMIC
val dark =
when (theme) {
@@ -141,7 +146,7 @@ class MainActivity : ComponentActivity() {
}
AppCompositionLocals {
- AppTheme(dynamicColor = dynamic, darkTheme = dark) {
+ AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
@@ -164,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(
@@ -255,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()
}
@@ -276,7 +296,7 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
- model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
+ model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
}
private fun createShareIntent(message: String): PendingIntent {
diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
index d32cc3df6..9228b6874 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
@@ -28,6 +28,7 @@ import androidx.work.WorkManager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
@@ -36,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
@@ -57,16 +57,15 @@ open class MeshUtilApplication :
Application(),
Configuration.Provider {
- private val applicationScope = CoroutineScope(Dispatchers.Default)
+ private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
ContextServices.app = this
- startKoin {
+ startKoin {
androidContext(this@MeshUtilApplication)
workManagerFactory()
- modules(AppKoinModule().module())
}
// Schedule periodic MeshLog cleanup
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
similarity index 67%
rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
index 7ca9f9fe8..04f0350c8 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
@@ -14,16 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.common.util
+package org.meshtastic.app.di
-import kotlin.test.Test
-import kotlin.test.assertEquals
+import org.koin.core.annotation.KoinApplication
-class MeshtasticUriTest {
- @Test
- fun testParseAndToString() {
- val uriString = "content://com.example.provider/file.txt"
- val uri = MeshtasticUri.parse(uriString)
- assertEquals(uriString, uri.toString())
- }
-}
+/**
+ * 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/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
index 4aa27bf0e..91ab81ec0 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
@@ -24,6 +24,8 @@ import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
+import coil3.memoryCacheMaxSizePercentWhileInBackground
+import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
@@ -31,19 +33,25 @@ import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.DefaultRequest
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.request.url
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
private const val DISK_CACHE_PERCENT = 0.02
private const val MEMORY_CACHE_PERCENT = 0.25
+private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1
@Module
class NetworkModule {
@@ -64,7 +72,12 @@ class NetworkModule {
buildConfigProvider: BuildConfigProvider,
): ImageLoader = ImageLoader.Builder(context = application)
.components {
- add(KtorNetworkFetcherFactory(httpClient = httpClient))
+ add(
+ KtorNetworkFetcherFactory(
+ httpClient = httpClient,
+ concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
+ ),
+ )
add(SvgDecoder.Factory(scaleToDensity = true))
}
.memoryCache {
@@ -77,6 +90,7 @@ class NetworkModule {
.build()
}
.logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
+ .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT)
.crossfade(enable = true)
.build()
@@ -84,6 +98,16 @@ class NetworkModule {
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) {
install(plugin = ContentNegotiation) { json(json) }
+ install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
+ install(plugin = HttpTimeout) {
+ requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ }
+ install(plugin = HttpRequestRetry) {
+ retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
+ exponentialDelay()
+ }
if (buildConfigProvider.isDebug) {
install(plugin = Logging) {
logger = KermitHttpLogger
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 fd432a1fa..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,16 +36,8 @@ class AndroidApplicationConventionPlugin : Plugin {
extensions.configure {
configureKotlinAndroid(this)
-
- defaultConfig {
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables.useSupportLibrary = true
- }
- testOptions {
- animationsDisabled = true
- unitTests.isReturnDefaultValues = true
- }
+ defaultConfig { vectorDrawables.useSupportLibrary = true }
buildTypes {
getByName("release") {
@@ -55,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") {
@@ -67,9 +58,7 @@ class AndroidApplicationConventionPlugin : Plugin {
}
}
- buildFeatures {
- buildConfig = true
- }
+ buildFeatures { buildConfig = true }
}
configureTestOptions()
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
index cf3ae81db..68771d24a 100644
--- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
@@ -38,11 +38,6 @@ class AndroidLibraryConventionPlugin : Plugin {
extensions.configure {
configureKotlinAndroid(this)
- defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- testOptions {
- animationsDisabled = true
- unitTests.isReturnDefaultValues = true
- }
defaultConfig {
// When flavorless modules depend on flavored modules (like :core:data),
diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
index 6af52cd50..be280f29c 100644
--- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
@@ -54,16 +54,18 @@ class KmpFeatureConventionPlugin : Plugin {
// Logging
implementation(libs.library("kermit"))
+
+ // @Preview available in commonMain since CMP 1.11 (androidx.compose.ui.tooling.preview.Preview)
+ // org.jetbrains.compose.ui.tooling.preview.Preview is deprecated in 1.11
+ implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
}
sourceSets.getByName("androidMain").dependencies {
// Common Android Compose dependencies
implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
- implementation(libs.library("compose-multiplatform-material3"))
implementation(libs.library("compose-multiplatform-ui"))
- implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
}
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }
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 580db4c4b..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
@@ -44,19 +44,19 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
compileSdk = compileSdkVersion
defaultConfig.minSdk = minSdkVersion
+ defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
if (this is ApplicationExtension) {
defaultConfig.targetSdk = targetSdkVersion
}
- val javaVersion = if (project.name in listOf("api", "model", "proto")) {
- JavaVersion.VERSION_17
- } else {
- JavaVersion.VERSION_21
- }
+ val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21
compileOptions.sourceCompatibility = javaVersion
compileOptions.targetCompatibility = javaVersion
+ testOptions.animationsDisabled = true
+ testOptions.unitTests.isReturnDefaultValues = true
+
// Exclude duplicate META-INF license files shipped by JUnit Platform JARs
packaging.resources.excludes.addAll(
listOf(
@@ -72,6 +72,23 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
/** Configure Kotlin Multiplatform options */
internal fun Project.configureKotlinMultiplatform() {
+ // Skiko is an internal CMP implementation detail; third-party KMP libraries
+ // (e.g. coil3) can carry an older skiko transitive requirement that Gradle
+ // upgrades to the CMP-bundled version, triggering a "Skiko dependencies'
+ // versions are incompatible" warning from CMP's compatibility checker.
+ // Force the version to match CMP so the checker sees a consistent graph.
+ // Pinned here rather than in the version catalog because this plugin is the
+ // only consumer — bump together with the compose-multiplatform version.
+ val skikoVersion = "0.144.5"
+ configurations.configureEach {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.jetbrains.skiko") {
+ useVersion(skikoVersion)
+ because("Align Skiko with the version bundled by Compose Multiplatform")
+ }
+ }
+ }
+
extensions.configure {
// Standard KMP targets for Meshtastic
jvm()
@@ -190,11 +207,25 @@ internal fun Project.configureKotlinJvm() {
configureKotlin()
}
+/** Modules published for external consumers — use Java 17 for broader compatibility. */
+private val PUBLISHED_MODULES = setOf("api", "model", "proto")
+
+/** Compiler args shared across all Kotlin targets (JVM, Android, iOS, etc.). */
+private val SHARED_COMPILER_ARGS = listOf(
+ "-opt-in=kotlin.uuid.ExperimentalUuidApi",
+ "-opt-in=kotlin.time.ExperimentalTime",
+ "-Xexpect-actual-classes",
+ "-Xcontext-parameters",
+ "-Xannotation-default-target=param-property",
+ "-Xskip-prerelease-check",
+)
+
/** Configure base Kotlin options */
private inline fun Project.configureKotlin() {
+ val isPublishedModule = project.name in PUBLISHED_MODULES
+
extensions.configure {
- val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21
- val isPublishedModule = project.name in listOf("api", "model", "proto")
+ val javaVersion = if (isPublishedModule) 17 else 21
// Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older environments),
// and Java 21 for the rest of the app.
jvmToolchain(javaVersion)
@@ -208,14 +239,7 @@ private inline fun Project.configureKotlin() {
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
- freeCompilerArgs.addAll(
- "-opt-in=kotlin.uuid.ExperimentalUuidApi",
- "-opt-in=kotlin.time.ExperimentalTime",
- "-Xexpect-actual-classes",
- "-Xcontext-parameters",
- "-Xannotation-default-target=param-property",
- "-Xskip-prerelease-check",
- )
+ freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
if (isJvmTarget) {
freeCompilerArgs.add("-jvm-default=no-compatibility")
}
@@ -230,21 +254,13 @@ private inline fun Project.configureKotlin() {
tasks.withType().configureEach {
compilerOptions {
- val isPublishedModule = project.name in listOf("api", "model", "proto")
jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21)
allWarningsAsErrors.set(warningsAsErrors)
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
- freeCompilerArgs.addAll(
- "-opt-in=kotlin.uuid.ExperimentalUuidApi",
- "-opt-in=kotlin.time.ExperimentalTime",
- "-Xexpect-actual-classes",
- "-Xcontext-parameters",
- "-Xannotation-default-target=param-property",
- "-Xskip-prerelease-check",
- "-jvm-default=no-compatibility",
- )
+ freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
+ freeCompilerArgs.add("-jvm-default=no-compatibility")
}
}
}
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/codecov.yml b/codecov.yml
index 6e0989227..7f77510ff 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -57,10 +57,6 @@ component_management:
name: Desktop
paths:
- desktop/**
- - component_id: example
- name: Example
- paths:
- - mesh_service_example/**
ignore:
- "**/build/**"
diff --git a/conductor/code_styleguides/general.md b/conductor/code_styleguides/general.md
deleted file mode 100644
index dfcc793f4..000000000
--- a/conductor/code_styleguides/general.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# General Code Style Principles
-
-This document outlines general coding principles that apply across all languages and frameworks used in this project.
-
-## Readability
-- Code should be easy to read and understand by humans.
-- Avoid overly clever or obscure constructs.
-
-## Consistency
-- Follow existing patterns in the codebase.
-- Maintain consistent formatting, naming, and structure.
-
-## Simplicity
-- Prefer simple solutions over complex ones.
-- Break down complex problems into smaller, manageable parts.
-
-## Maintainability
-- Write code that is easy to modify and extend.
-- Minimize dependencies and coupling.
-
-## Documentation
-- Document *why* something is done, not just *what*.
-- Keep documentation up-to-date with code changes.
diff --git a/conductor/index.md b/conductor/index.md
deleted file mode 100644
index 3a362bc99..000000000
--- a/conductor/index.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Project Context
-
-## Definition
-- [Product Definition](./product.md)
-- [Product Guidelines](./product-guidelines.md)
-- [Tech Stack](./tech-stack.md)
-
-## Workflow
-- [Workflow](./workflow.md)
-- [Code Style Guides](./code_styleguides/)
-
-## Management
-- [Tracks Registry](./tracks.md)
-- [Tracks Directory](./tracks/)
\ No newline at end of file
diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md
deleted file mode 100644
index b54944fea..000000000
--- a/conductor/product-guidelines.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Product Guidelines
-
-## Brand Voice and Tone
-- **Technical yet Accessible:** Communicate complex networking and hardware concepts clearly without being overly academic.
-- **Reliable and Authoritative:** The app is a utility for critical, off-grid communication. Language should convey stability and safety.
-- **Community-Oriented:** Encourage open-source participation and community support.
-
-## UX Principles
-- **Offline-First:** Assume the user has no cellular or Wi-Fi connection. All core functions must work locally via the mesh network.
-- **Adaptive Layouts:** Support multiple form factors seamlessly (phones, tablets, desktop) using Material 3 Adaptive Scaffold principles.
-- **Information Density:** Give power users access to detailed metrics (SNR, battery, hop limits) without overwhelming beginners. Use progressive disclosure.
-
-## Prose Style
-- **Clarity over cleverness:** Use plain English.
-- **Action-oriented:** Button labels and prompts should start with strong verbs (e.g., "Send", "Connect", "Export").
-- **Consistent Terminology:**
- - Use "Node" for devices on the network.
- - Use "Channel" for communication groups.
- - Use "Direct Message" for 1-to-1 communication.
\ No newline at end of file
diff --git a/conductor/product.md b/conductor/product.md
deleted file mode 100644
index edfac5083..000000000
--- a/conductor/product.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# Initial Concept
-A tool for using Android with open-source mesh radios.
-
-# Product Guide
-
-## Overview
-Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facilitate communication over off-grid, decentralized mesh networks using open-source hardware radios.
-
-## Target Audience
-- Off-grid communication enthusiasts and hobbyists
-- Outdoor adventurers needing reliable communication without cellular networks
-- Emergency response and disaster relief teams
-
-## Core Features
-- Direct communication with Meshtastic hardware (via BLE, USB, TCP, MQTT)
-- Decentralized text messaging across the mesh network
-- Unified cross-platform notifications for messages and node events
-- Adaptive node and contact management
-- Offline map rendering and device positioning
-- Device configuration and firmware updates
-- Unified cross-platform debugging and packet inspection
-
-## Key Architecture Goals
-- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS)
-- Ensure offline-first functionality and resilient data persistence (Room 3 KMP)
-- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform
\ No newline at end of file
diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md
deleted file mode 100644
index 75237887b..000000000
--- a/conductor/tech-stack.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Tech Stack
-
-## Programming Language
-- **Kotlin Multiplatform (KMP):** The core logic is shared across Android, Desktop, and iOS using `commonMain`.
-
-## Frontend Frameworks
-- **Compose Multiplatform:** Shared UI layer for rendering on Android and Desktop.
-- **Jetpack Compose:** Used where platform-specific UI (like charts or permissions) is necessary on Android.
-
-## Background & Services
-- **Platform Services:** Core service orchestrations and background work are abstracted into `core:service` to maximize logic reuse across targets, using platform-specific implementations (e.g., WorkManager/Service on Android) only where necessary.
-
-## Architecture
-- **MVI / Unidirectional Data Flow:** Shared view models using the multiplatform `androidx.lifecycle.ViewModel`.
-- **JetBrains Navigation 3:** Multiplatform fork for state-based, compose-first navigation without relying on `NavController`. Navigation graphs are decoupled and extracted into their respective `feature:*` modules, allowing a thinned out root `app` module.
-
-## Dependency Injection
-- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt.
-
-## Database & Storage
-- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and platform-appropriate SQLite drivers (e.g., `BundledSQLiteDriver` for JVM/Desktop, Framework driver for Android).
-- **Jetpack DataStore:** Shared preferences.
-
-## Networking & Transport
-- **Ktor:** Multiplatform HTTP client for web services and TCP streaming.
-- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS).
-- **jSerialComm:** Cross-platform Java library used for direct Serial/USB communication with Meshtastic devices on the Desktop (JVM) target.
-- **KMQTT:** Kotlin Multiplatform MQTT client and broker used for MQTT transport, replacing the Android-only Paho library.
-- **Coroutines & Flows:** For asynchronous programming and state management.
-
-## Testing (KMP)
-- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`.
-- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows.
-- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`.
-- **Platform-Specific Verification:** Use **Robolectric** on the Android host target to verify KMP modules that interact with Android framework components (like `Uri` or `Room`).
-- **Subclassing Pattern:** Maintain a unified test suite by defining abstract base tests in `commonTest` and platform-specific subclasses in `jvmTest` and `androidHostTest` for initialization (e.g., calling `setupTestContext()`).
-- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates.
-- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios.
\ No newline at end of file
diff --git a/conductor/tracks.md b/conductor/tracks.md
deleted file mode 100644
index 0b5c54e3d..000000000
--- a/conductor/tracks.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Project Tracks
-
-This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
-
----
diff --git a/conductor/workflow.md b/conductor/workflow.md
deleted file mode 100644
index 6f9cfd8fc..000000000
--- a/conductor/workflow.md
+++ /dev/null
@@ -1,333 +0,0 @@
-# Project Workflow
-
-## Guiding Principles
-
-1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md`
-2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation
-3. **Test-Driven Development:** Write unit tests before implementing functionality
-4. **High Code Coverage:** Aim for >80% code coverage for all modules
-5. **User Experience First:** Every decision should prioritize user experience
-6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution.
-
-## Task Workflow
-
-All tasks follow a strict lifecycle:
-
-### Standard Task Workflow
-
-1. **Select Task:** Choose the next available task from `plan.md` in sequential order
-
-2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]`
-
-3. **Write Failing Tests (Red Phase):**
- - Create a new test file for the feature or bug fix.
- - Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task.
- - **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests.
-
-4. **Implement to Pass Tests (Green Phase):**
- - Write the minimum amount of application code necessary to make the failing tests pass.
- - Run the test suite again and confirm that all tests now pass. This is the "Green" phase.
-
-5. **Refactor (Optional but Recommended):**
- - With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior.
- - Rerun tests to ensure they still pass after refactoring.
-
-6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like:
- ```bash
- pytest --cov=app --cov-report=html
- ```
- Target: >80% coverage for new code. The specific tools and commands will vary by language and framework.
-
-7. **Document Deviations:** If implementation differs from tech stack:
- - **STOP** implementation
- - Update `tech-stack.md` with new design
- - Add dated note explaining the change
- - Resume implementation
-
-8. **Commit Code Changes:**
- - Stage all code changes related to the task.
- - Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`.
- - Perform the commit.
-
-9. **Attach Task Summary with Git Notes:**
- - **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`).
- - **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change.
- - **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit.
- ```bash
- # The note content from the previous step is passed via the -m flag.
- git notes add -m ""
- ```
-
-10. **Get and Record Task Commit SHA:**
- - **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash.
- - **Step 10.2: Write Plan:** Write the updated content back to `plan.md`.
-
-11. **Commit Plan Update:**
- - **Action:** Stage the modified `plan.md` file.
- - **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`).
-
-### Phase Completion Verification and Checkpointing Protocol
-
-**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`.
-
-1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun.
-
-2. **Ensure Test Coverage for Phase Changes:**
- - **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit.
- - **Step 2.2: List Changed Files:** Execute `git diff --name-only HEAD` to get a precise list of all files modified during this phase.
- - **Step 2.3: Verify and Create Tests:** For each file in the list:
- - **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`).
- - For each remaining code file, verify a corresponding test file exists.
- - If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`).
-
-3. **Execute Automated Tests with Proactive Debugging:**
- - Before execution, you **must** announce the exact shell command you will use to run the tests.
- - **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`"
- - Execute the announced command.
- - If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance.
-
-4. **Propose a Detailed, Actionable Manual Verification Plan:**
- - **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase.
- - You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes.
- - The plan you present to the user **must** follow this format:
-
- **For a Frontend Change:**
- ```
- The automated tests have passed. For manual verification, please follow these steps:
-
- **Manual Verification Steps:**
- 1. **Start the development server with the command:** `npm run dev`
- 2. **Open your browser to:** `http://localhost:3000`
- 3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly.
- ```
-
- **For a Backend Change:**
- ```
- The automated tests have passed. For manual verification, please follow these steps:
-
- **Manual Verification Steps:**
- 1. **Ensure the server is running.**
- 2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'`
- 3. **Confirm that you receive:** A JSON response with a status of `201 Created`.
- ```
-
-5. **Await Explicit User Feedback:**
- - After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**"
- - **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation.
-
-6. **Create Checkpoint Commit:**
- - Stage all changes. If no changes occurred in this step, proceed with an empty commit.
- - Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`).
-
-7. **Attach Auditable Verification Report using Git Notes:**
- - **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation.
- - **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit.
-
-8. **Get and Record Phase Checkpoint SHA:**
- - **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`).
- - **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: ]`.
- - **Step 8.3: Write Plan:** Write the updated content back to `plan.md`.
-
-9. **Commit Plan Update:**
- - **Action:** Stage the modified `plan.md` file.
- - **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '' as complete`.
-
-10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note.
-
-### Quality Gates
-
-Before marking any task complete, verify:
-
-- [ ] All tests pass
-- [ ] Code coverage meets requirements (>80%)
-- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`)
-- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc)
-- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types)
-- [ ] No linting or static analysis errors (using the project's configured tools)
-- [ ] Works correctly on mobile (if applicable)
-- [ ] Documentation updated if needed
-- [ ] No security vulnerabilities introduced
-
-## Development Commands
-
-**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.**
-
-### Setup
-```bash
-# Example: Commands to set up the development environment (e.g., install dependencies, configure database)
-# e.g., for a Node.js project: npm install
-# e.g., for a Go project: go mod tidy
-```
-
-### Daily Development
-```bash
-# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format)
-# e.g., for a Node.js project: npm run dev, npm test, npm run lint
-# e.g., for a Go project: go run main.go, go test ./..., go fmt ./...
-```
-
-### Before Committing
-```bash
-# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests)
-# e.g., for a Node.js project: npm run check
-# e.g., for a Go project: make check (if a Makefile exists)
-```
-
-## Testing Requirements
-
-### Unit Testing
-- Every module must have corresponding tests.
-- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach).
-- Mock external dependencies.
-- Test both success and failure cases.
-
-### Integration Testing
-- Test complete user flows
-- Verify database transactions
-- Test authentication and authorization
-- Check form submissions
-
-### Mobile Testing
-- Test on actual iPhone when possible
-- Use Safari developer tools
-- Test touch interactions
-- Verify responsive layouts
-- Check performance on 3G/4G
-
-## Code Review Process
-
-### Self-Review Checklist
-Before requesting review:
-
-1. **Functionality**
- - Feature works as specified
- - Edge cases handled
- - Error messages are user-friendly
-
-2. **Code Quality**
- - Follows style guide
- - DRY principle applied
- - Clear variable/function names
- - Appropriate comments
-
-3. **Testing**
- - Unit tests comprehensive
- - Integration tests pass
- - Coverage adequate (>80%)
-
-4. **Security**
- - No hardcoded secrets
- - Input validation present
- - SQL injection prevented
- - XSS protection in place
-
-5. **Performance**
- - Database queries optimized
- - Images optimized
- - Caching implemented where needed
-
-6. **Mobile Experience**
- - Touch targets adequate (44x44px)
- - Text readable without zooming
- - Performance acceptable on mobile
- - Interactions feel native
-
-## Commit Guidelines
-
-### Message Format
-```
-():
-
-[optional body]
-
-[optional footer]
-```
-
-### Types
-- `feat`: New feature
-- `fix`: Bug fix
-- `docs`: Documentation only
-- `style`: Formatting, missing semicolons, etc.
-- `refactor`: Code change that neither fixes a bug nor adds a feature
-- `test`: Adding missing tests
-- `chore`: Maintenance tasks
-
-### Examples
-```bash
-git commit -m "feat(auth): Add remember me functionality"
-git commit -m "fix(posts): Correct excerpt generation for short posts"
-git commit -m "test(comments): Add tests for emoji reaction limits"
-git commit -m "style(mobile): Improve button touch targets"
-```
-
-## Definition of Done
-
-A task is complete when:
-
-1. All code implemented to specification
-2. Unit tests written and passing
-3. Code coverage meets project requirements
-4. Documentation complete (if applicable)
-5. Code passes all configured linting and static analysis checks
-6. Works beautifully on mobile (if applicable)
-7. Implementation notes added to `plan.md`
-8. Changes committed with proper message
-9. Git note with task summary attached to the commit
-
-## Emergency Procedures
-
-### Critical Bug in Production
-1. Create hotfix branch from main
-2. Write failing test for bug
-3. Implement minimal fix
-4. Test thoroughly including mobile
-5. Deploy immediately
-6. Document in plan.md
-
-### Data Loss
-1. Stop all write operations
-2. Restore from latest backup
-3. Verify data integrity
-4. Document incident
-5. Update backup procedures
-
-### Security Breach
-1. Rotate all secrets immediately
-2. Review access logs
-3. Patch vulnerability
-4. Notify affected users (if any)
-5. Document and update security procedures
-
-## Deployment Workflow
-
-### Pre-Deployment Checklist
-- [ ] All tests passing
-- [ ] Coverage >80%
-- [ ] No linting errors
-- [ ] Mobile testing complete
-- [ ] Environment variables configured
-- [ ] Database migrations ready
-- [ ] Backup created
-
-### Deployment Steps
-1. Merge feature branch to main
-2. Tag release with version
-3. Push to deployment service
-4. Run database migrations
-5. Verify deployment
-6. Test critical paths
-7. Monitor for errors
-
-### Post-Deployment
-1. Monitor analytics
-2. Check error logs
-3. Gather user feedback
-4. Plan next iteration
-
-## Continuous Improvement
-
-- Review workflow weekly
-- Update based on pain points
-- Document lessons learned
-- Optimize for user happiness
-- Keep things simple and maintainable
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/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
index c636d4718..5e85a52f8 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
@@ -48,9 +48,7 @@ suspend fun retryBleOperation(
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
throw e
}
- Logger.w(e) {
- "[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
- }
+ Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." }
delay(delayMs)
}
}
diff --git a/core/common/README.md b/core/common/README.md
index da7700ac5..979586213 100644
--- a/core/common/README.md
+++ b/core/common/README.md
@@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers:
- **Time**: Utilities for handling timestamps and durations.
- **Exceptions**: Standardized exception types for common error scenarios.
-### 2. `ByteUtils.kt`
-Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
+### 2. `MetricFormatter.kt`
+Centralized utility for display strings — temperature, voltage, current, percent, humidity, pressure, SNR, RSSI. Ensures consistent unit spacing and formatting across all UI surfaces.
### 3. `BuildConfigProvider.kt`
An interface for accessing build-time configuration in a multiplatform-friendly way.
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 08ec08865..e4d94943e 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -37,6 +37,7 @@ kotlin {
implementation(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
api(libs.okio)
+ api(libs.uri.kmp)
implementation(libs.kermit)
}
androidMain.dependencies { api(libs.androidx.core.ktx) }
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
deleted file mode 100644
index a99bccd84..000000000
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.core.common.util
-
-import android.net.Uri
-
-actual class CommonUri(private val uri: Uri) {
- actual val host: String?
- get() = uri.host
-
- actual val fragment: String?
- get() = uri.fragment
-
- actual val pathSegments: List
- get() = uri.pathSegments
-
- actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key)
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
- uri.getBooleanQueryParameter(key, defaultValue)
-
- actual override fun toString(): String = uri.toString()
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString))
- }
-
- fun toUri(): Uri = uri
-}
-
-actual fun CommonUri.toPlatformUri(): Any = this.toUri()
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt
deleted file mode 100644
index c27040e73..000000000
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (c) 2025 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
-
-/** Utility function to make it easy to declare byte arrays */
-fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
-
-fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) }
-
-private const val BYTE_MASK = 0xff
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/MeshtasticUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
similarity index 62%
rename from core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
index 0babff5b1..1072801c6 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2026 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
@@ -17,13 +17,14 @@
package org.meshtastic.core.common.util
/**
- * A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain
- * modules without coupling them to the android.net.Uri class.
+ * Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null,
+ * blank, or sentinel values (`"N"`, `"NULL"`).
*/
-data class MeshtasticUri(val uriString: String) {
- override fun toString(): String = uriString
-
- companion object {
- fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString)
+fun normalizeAddress(addr: String?): String {
+ val u = addr?.trim()?.uppercase()
+ return when {
+ u.isNullOrBlank() -> "DEFAULT"
+ u == "N" || u == "NULL" -> "DEFAULT"
+ else -> u.replace(":", "")
}
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
index 7079cbf5e..00b15861f 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
@@ -16,22 +16,14 @@
*/
package org.meshtastic.core.common.util
-/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */
-expect class CommonUri {
- val host: String?
- val fragment: String?
- val pathSegments: List
+import com.eygraber.uri.Uri
- fun getQueryParameter(key: String): String?
-
- fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean
-
- override fun toString(): String
-
- companion object {
- fun parse(uriString: String): CommonUri
- }
-}
-
-/** Extension to convert platform Uri to CommonUri in Android source sets. */
-expect fun CommonUri.toPlatformUri(): Any
+/**
+ * Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp).
+ *
+ * This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works
+ * identically on Android, JVM, and iOS without platform stubs.
+ *
+ * On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`.
+ */
+typealias CommonUri = Uri
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 ccd565286..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
@@ -17,6 +17,7 @@
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
object Exceptions {
/** Set by the application to provide a custom crash reporting implementation. */
@@ -47,10 +48,12 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
}
}
-/** Suspend-compatible variant of [ignoreException]. */
+/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */
suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) {
try {
inner()
+ } catch (e: CancellationException) {
+ throw e
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
if (!silent) {
Logger.w(ex) { "Ignoring exception" }
@@ -69,3 +72,41 @@ fun exceptionReporter(inner: () -> Unit) {
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
+
+/**
+ * Like [kotlin.runCatching], but re-throws [CancellationException] to preserve structured concurrency. Use this instead
+ * of [runCatching] in coroutine contexts.
+ */
+@Suppress("TooGenericExceptionCaught")
+inline fun safeCatching(block: () -> T): Result = try {
+ Result.success(block())
+} catch (e: CancellationException) {
+ throw e
+} catch (e: Exception) {
+ Result.failure(e)
+}
+
+/** Like [kotlin.runCatching] receiver variant, but re-throws [CancellationException]. */
+@Suppress("TooGenericExceptionCaught")
+inline fun T.safeCatching(block: T.() -> R): Result = try {
+ Result.success(block())
+} catch (e: CancellationException) {
+ throw e
+} 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/Formatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
index d54455df8..7a24819a7 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
@@ -16,5 +16,114 @@
*/
package org.meshtastic.core.common.util
-/** Multiplatform string formatting helper. */
-expect fun formatString(pattern: String, vararg args: Any?): String
+/**
+ * Pure-Kotlin multiplatform string formatting.
+ *
+ * Implements the subset of Java's `String.format()` patterns used in this codebase:
+ * - `%s`, `%d` — positional or sequential string/integer
+ * - `%N$s`, `%N$d` — explicit positional string/integer
+ * - `%N$.Nf`, `%.Nf` — float with decimal precision
+ * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
+ * - `%%` — literal percent
+ */
+@Suppress("CyclomaticComplexMethod", "LongMethod", "LoopWithTooManyJumpStatements")
+fun formatString(pattern: String, vararg args: Any?): String = buildString {
+ var i = 0
+ var autoIndex = 0
+ while (i < pattern.length) {
+ if (pattern[i] != '%') {
+ append(pattern[i])
+ i++
+ continue
+ }
+ i++ // skip '%'
+ if (i >= pattern.length) break
+
+ // Literal %%
+ if (pattern[i] == '%') {
+ append('%')
+ i++
+ continue
+ }
+
+ // Parse optional positional index (N$)
+ var explicitIndex: Int? = null
+ val startPos = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i < pattern.length && pattern[i] == '$' && i > startPos) {
+ explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
+ i++ // skip '$'
+ } else {
+ i = startPos // rewind — digits are part of width/precision, not positional index
+ }
+
+ // Parse optional flags (zero-pad)
+ var zeroPad = false
+ if (i < pattern.length && pattern[i] == '0') {
+ zeroPad = true
+ i++
+ }
+
+ // Parse optional width
+ var width: Int? = null
+ val widthStart = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i > widthStart) {
+ width = pattern.substring(widthStart, i).toInt()
+ }
+
+ // Parse optional precision (.N)
+ var precision: Int? = null
+ if (i < pattern.length && pattern[i] == '.') {
+ i++ // skip '.'
+ val precStart = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i > precStart) {
+ precision = pattern.substring(precStart, i).toInt()
+ }
+ }
+
+ // Parse conversion character
+ if (i >= pattern.length) break
+ val conversion = pattern[i]
+ i++
+
+ val argIndex = explicitIndex ?: autoIndex++
+ val arg = args.getOrNull(argIndex)
+
+ when (conversion) {
+ 's' -> append(arg?.toString() ?: "null")
+ 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
+ 'f' -> {
+ val value = (arg as? Number)?.toDouble() ?: 0.0
+ val places = precision ?: DEFAULT_FLOAT_PRECISION
+ append(NumberFormatter.format(value, places))
+ }
+ 'x',
+ 'X',
+ -> {
+ val value = (arg as? Number)?.toLong() ?: 0L
+ // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
+ val masked = if (arg is Int) value and INT_MASK else value
+ var hex = masked.toString(HEX_RADIX)
+ if (conversion == 'X') hex = hex.uppercase()
+ val padChar = if (zeroPad) '0' else ' '
+ val padWidth = width ?: 0
+ append(hex.padStart(padWidth, padChar))
+ }
+ else -> {
+ // Unknown conversion — reproduce original token
+ append('%')
+ if (explicitIndex != null) append("${explicitIndex + 1}$")
+ if (zeroPad) append('0')
+ if (width != null) append(width)
+ if (precision != null) append(".$precision")
+ append(conversion)
+ }
+ }
+ }
+}
+
+private const val DEFAULT_FLOAT_PRECISION = 6
+private const val HEX_RADIX = 16
+private const val INT_MASK = 0xFFFFFFFFL
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
index e3612dfda..1abb8807c 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
@@ -79,9 +79,7 @@ object HomoglyphCharacterStringTransformer {
* @param value original string value.
* @return optimized string value.
*/
- fun optimizeUtf8StringWithHomoglyphs(value: String): String {
- val stringBuilder = StringBuilder()
- for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
- return stringBuilder.toString()
+ fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString {
+ for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c)
}
}
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
new file mode 100644
index 000000000..51905ff41
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.util
+
+/**
+ * Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node,
+ * NodeItem, and metric screens.
+ *
+ * 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 {
+ val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius
+ val unit = if (isFahrenheit) "°F" else "°C"
+ return "${NumberFormatter.format(value, 1)}$unit"
+ }
+
+ fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V"
+
+ fun current(milliAmps: Float, decimalPlaces: Int = 1): String =
+ "${NumberFormatter.format(milliAmps, decimalPlaces)} mA"
+
+ fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%"
+
+ fun percent(value: Int): String = "$value%"
+
+ fun humidity(value: Float): String = percent(value, 0)
+
+ fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
+
+ 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
+private const val FAHRENHEIT_OFFSET = 32
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt
new file mode 100644
index 000000000..040861b8d
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class AddressUtilsTest {
+
+ @Test
+ fun nullReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress(null))
+ }
+
+ @Test
+ fun blankReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress(""))
+ assertEquals("DEFAULT", normalizeAddress(" "))
+ }
+
+ @Test
+ fun sentinelNReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress("N"))
+ assertEquals("DEFAULT", normalizeAddress("n"))
+ }
+
+ @Test
+ fun sentinelNullReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress("NULL"))
+ assertEquals("DEFAULT", normalizeAddress("null"))
+ assertEquals("DEFAULT", normalizeAddress("Null"))
+ }
+
+ @Test
+ fun stripsColons() {
+ assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD"))
+ }
+
+ @Test
+ fun uppercases() {
+ assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd"))
+ }
+
+ @Test
+ fun trimsWhitespace() {
+ assertEquals("AABBCC", normalizeAddress(" AA:BB:CC "))
+ }
+
+ @Test
+ fun alreadyNormalizedPassesThrough() {
+ assertEquals("AABBCCDD", normalizeAddress("AABBCCDD"))
+ }
+
+ @Test
+ fun mixedCaseWithColons() {
+ assertEquals("AABBCC", normalizeAddress("aA:Bb:cC"))
+ }
+}
diff --git a/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
similarity index 51%
rename from core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
index 0e754708c..899938ba4 100644
--- a/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
@@ -18,27 +18,26 @@ package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
-import kotlin.test.assertTrue
class CommonUriTest {
-
@Test
- fun testParse() {
- val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1¶m2=true#fragment")
- assertEquals("meshtastic.org", uri.host)
- assertEquals("fragment", uri.fragment)
- assertEquals(listOf("path", "to", "page"), uri.pathSegments)
- assertEquals("value1", uri.getQueryParameter("param1"))
- assertTrue(uri.getBooleanQueryParameter("param2", false))
+ fun testParseAndToString() {
+ val uriString = "content://com.example.provider/file.txt"
+ val uri = CommonUri.parse(uriString)
+ assertEquals(uriString, uri.toString())
}
@Test
- fun testBooleanParameters() {
- val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
- assertTrue(uri.getBooleanQueryParameter("t1", false))
- assertTrue(uri.getBooleanQueryParameter("t2", false))
- assertTrue(uri.getBooleanQueryParameter("t3", false))
- assertTrue(!uri.getBooleanQueryParameter("f1", true))
- assertTrue(!uri.getBooleanQueryParameter("f2", true))
+ fun testQueryParameters() {
+ val uri = CommonUri.parse("https://meshtastic.org/d/#key=value&complete=true")
+ assertEquals("meshtastic.org", uri.host)
+ assertEquals("key=value&complete=true", uri.fragment)
+ }
+
+ @Test
+ fun testFileUri() {
+ val uri = CommonUri.parse("file:///tmp/export.csv")
+ assertEquals("file", uri.scheme)
+ assertEquals("/tmp/export.csv", uri.path)
}
}
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
index 94b81f0fb..de2d20e9e 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
@@ -93,4 +93,48 @@ class FormatStringTest {
fun sequentialFloatSubstitution() {
assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45))
}
+
+ // Hex format tests
+
+ @Test
+ fun lowercaseHex() {
+ assertEquals("ff", formatString("%x", 255))
+ }
+
+ @Test
+ fun uppercaseHex() {
+ assertEquals("FF", formatString("%X", 255))
+ }
+
+ @Test
+ fun zeroPaddedHex() {
+ assertEquals("000000ff", formatString("%08x", 255))
+ }
+
+ @Test
+ fun zeroPaddedHexNodeId() {
+ assertEquals("!deadbeef", formatString("!%08x", 0xDEADBEEF.toInt()))
+ }
+
+ @Test
+ fun hexZeroValue() {
+ assertEquals("00000000", formatString("%08x", 0))
+ }
+
+ @Test
+ fun positionalHex() {
+ assertEquals("Node ff id 42", formatString("Node %1\$x id %2\$d", 255, 42))
+ }
+
+ // Edge case tests
+
+ @Test
+ fun trailingPercent() {
+ assertEquals("hello", formatString("hello%"))
+ }
+
+ @Test
+ fun outOfBoundsArgIndex() {
+ assertEquals("null", formatString("%3\$s", "only_one"))
+ }
}
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
new file mode 100644
index 000000000..94781fca3
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
@@ -0,0 +1,143 @@
+/*
+ * 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.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MetricFormatterTest {
+
+ @Test
+ fun temperatureCelsius() {
+ assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false))
+ }
+
+ @Test
+ fun temperatureFahrenheit() {
+ assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun temperatureNegative() {
+ assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false))
+ }
+
+ @Test
+ fun voltage() {
+ assertEquals("3.72 V", MetricFormatter.voltage(3.72f))
+ }
+
+ @Test
+ fun voltageOneDecimal() {
+ assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1))
+ }
+
+ @Test
+ fun current() {
+ assertEquals("150.3 mA", MetricFormatter.current(150.3f))
+ }
+
+ @Test
+ fun percentFloat() {
+ assertEquals("85.5%", MetricFormatter.percent(85.5f))
+ }
+
+ @Test
+ fun percentInt() {
+ assertEquals("85%", MetricFormatter.percent(85))
+ }
+
+ @Test
+ fun humidity() {
+ assertEquals("65%", MetricFormatter.humidity(65.4f))
+ }
+
+ @Test
+ fun pressure() {
+ assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f))
+ }
+
+ @Test
+ fun snr() {
+ assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
+ }
+
+ @Test
+ fun rssi() {
+ assertEquals("-90 dBm", MetricFormatter.rssi(-90))
+ }
+
+ @Test
+ fun temperatureFreezingFahrenheit() {
+ assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun temperatureBoilingFahrenheit() {
+ assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun voltageZero() {
+ assertEquals("0.00 V", MetricFormatter.voltage(0.0f))
+ }
+
+ @Test
+ fun currentZero() {
+ assertEquals("0.0 mA", MetricFormatter.current(0.0f))
+ }
+
+ @Test
+ fun percentZero() {
+ assertEquals("0%", MetricFormatter.percent(0))
+ }
+
+ @Test
+ fun percentHundred() {
+ assertEquals("100%", MetricFormatter.percent(100))
+ }
+
+ @Test
+ fun rssiZero() {
+ assertEquals("0 dBm", MetricFormatter.rssi(0))
+ }
+
+ @Test
+ 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/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
deleted file mode 100644
index c2e95a5b0..000000000
--- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * 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.util
-
-/**
- * Apple (iOS) implementation of string formatting.
- *
- * Implements a subset of Java's `String.format()` patterns used in this codebase:
- * - `%s`, `%d` — positional or sequential string/integer
- * - `%N$s`, `%N$d` — explicit positional string/integer
- * - `%N$.Nf`, `%.Nf` — float with decimal precision
- * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
- * - `%%` — literal percent
- *
- * This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions).
- */
-actual fun formatString(pattern: String, vararg args: Any?): String = buildString {
- var i = 0
- var autoIndex = 0
- while (i < pattern.length) {
- if (pattern[i] != '%') {
- append(pattern[i])
- i++
- continue
- }
- i++ // skip '%'
- if (i >= pattern.length) break
-
- // Literal %%
- if (pattern[i] == '%') {
- append('%')
- i++
- continue
- }
-
- // Parse optional positional index (N$)
- var explicitIndex: Int? = null
- val startPos = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i < pattern.length && pattern[i] == '$' && i > startPos) {
- explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
- i++ // skip '$'
- } else {
- i = startPos // rewind — digits are part of width/precision, not positional index
- }
-
- // Parse optional flags (zero-pad)
- var zeroPad = false
- if (i < pattern.length && pattern[i] == '0') {
- zeroPad = true
- i++
- }
-
- // Parse optional width
- var width: Int? = null
- val widthStart = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i > widthStart) {
- width = pattern.substring(widthStart, i).toInt()
- }
-
- // Parse optional precision (.N)
- var precision: Int? = null
- if (i < pattern.length && pattern[i] == '.') {
- i++ // skip '.'
- val precStart = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i > precStart) {
- precision = pattern.substring(precStart, i).toInt()
- }
- }
-
- // Parse conversion character
- if (i >= pattern.length) break
- val conversion = pattern[i]
- i++
-
- val argIndex = explicitIndex ?: autoIndex++
- val arg = args.getOrNull(argIndex)
-
- when (conversion) {
- 's' -> append(arg?.toString() ?: "null")
- 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
- 'f' -> {
- val value = (arg as? Number)?.toDouble() ?: 0.0
- val places = precision ?: DEFAULT_FLOAT_PRECISION
- append(NumberFormatter.format(value, places))
- }
- 'x',
- 'X',
- -> {
- val value = (arg as? Number)?.toLong() ?: 0L
- // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
- val masked = if (arg is Int) value and INT_MASK else value
- var hex = masked.toString(HEX_RADIX)
- if (conversion == 'X') hex = hex.uppercase()
- val padChar = if (zeroPad) '0' else ' '
- val padWidth = width ?: 0
- append(hex.padStart(padWidth, padChar))
- }
- else -> {
- // Unknown conversion — reproduce original token
- append('%')
- if (explicitIndex != null) append("${explicitIndex + 1}$")
- if (zeroPad) append('0')
- if (width != null) append(width)
- if (precision != null) append(".$precision")
- append(conversion)
- }
- }
- }
-}
-
-private const val DEFAULT_FLOAT_PRECISION = 6
-private const val HEX_RADIX = 16
-private const val INT_MASK = 0xFFFFFFFFL
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
index 35e2906ff..7556105b3 100644
--- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
@@ -22,20 +22,6 @@ actual object BuildUtils {
actual val sdkInt: Int = 0
}
-actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List) {
- actual fun getQueryParameter(key: String): String? = null
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue
-
- actual override fun toString(): String = ""
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList())
- }
-}
-
-actual fun CommonUri.toPlatformUri(): Any = Any()
-
actual object DateFormatter {
actual fun formatRelativeTime(timestampMillis: Long): String = ""
diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
deleted file mode 100644
index a450b9856..000000000
--- a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * 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.util
-
-/** JVM/Android implementation of string formatting. */
-actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)
diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt
deleted file mode 100644
index c10c015bc..000000000
--- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.core.common.util
-
-import java.net.URI
-
-actual class CommonUri(private val uri: URI) {
- private val queryParameters: Map> by lazy { parseQueryParameters(uri.rawQuery) }
-
- actual val host: String?
- get() = uri.host
-
- actual val fragment: String?
- get() = uri.fragment
-
- actual val pathSegments: List
- get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() }
-
- actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull()
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean {
- val value = getQueryParameter(key) ?: return defaultValue
- return value != "false" && value != "0"
- }
-
- actual override fun toString(): String = uri.toString()
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString))
- }
-
- fun toUri(): URI = uri
-}
-
-actual fun CommonUri.toPlatformUri(): Any = this.toUri()
diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
index 4b8abdbd3..43ead91a2 100644
--- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
+++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
@@ -17,9 +17,6 @@
package org.meshtastic.core.common.util
import java.net.InetAddress
-import java.net.URLDecoder
-import java.nio.charset.StandardCharsets
-import java.text.DateFormat
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -76,7 +73,7 @@ actual object DateFormatter {
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDateTimeShort(timestampMillis: Long): String =
- DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis)
+ shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
}
@Suppress("MagicNumber")
@@ -101,21 +98,6 @@ actual fun String?.isValidAddress(): Boolean {
}
}
-internal fun parseQueryParameters(rawQuery: String?): Map> = rawQuery
- ?.split('&')
- ?.filter { it.isNotBlank() }
- ?.groupBy(
- keySelector = { segment ->
- val key = segment.substringBefore('=', missingDelimiterValue = segment)
- URLDecoder.decode(key, StandardCharsets.UTF_8.name())
- },
- valueTransform = { segment ->
- val value = segment.substringAfter('=', missingDelimiterValue = "")
- URLDecoder.decode(value, StandardCharsets.UTF_8.name())
- },
- )
- .orEmpty()
-
private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}")
private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?.
+ */
+package org.meshtastic.core.data.manager
+
+import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.proto.Heartbeat
+import org.meshtastic.proto.ToRadio
+
+/**
+ * Centralized heartbeat sender for the data layer.
+ *
+ * Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's
+ * per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats.
+ *
+ * This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer
+ * with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler].
+ */
+@Single
+class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) {
+ private val nonce = atomic(0)
+
+ /**
+ * Enqueues a heartbeat with a unique nonce.
+ *
+ * @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage")
+ */
+ @Suppress("TooGenericExceptionCaught")
+ fun sendHeartbeat(tag: String = "handshake") {
+ try {
+ val n = nonce.incrementAndGet()
+ packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)))
+ Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" }
+ } catch (e: Exception) {
+ Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" }
+ }
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
index b0b9e8c5f..628528391 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.PacketHandler
@@ -94,7 +95,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
"lastRequest=$lastRequest window=$window max=$max",
)
- runCatching {
+ safeCatching {
packetHandler.sendToRadio(
MeshPacket(
from = myNodeNum,
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 5fd34e02e..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
@@ -26,6 +26,7 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
@@ -44,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
@@ -62,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,
@@ -93,7 +96,7 @@ class MeshActionHandlerImpl(
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
- runCatching {
+ safeCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
@@ -206,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/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index b7b27aa4e..cc5cc4319 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -20,18 +20,17 @@ import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
-import okio.IOException
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
@@ -39,9 +38,7 @@ import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
-import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
-import org.meshtastic.proto.ToRadio
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
@@ -56,7 +53,7 @@ class MeshConfigFlowManagerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
private val commandSender: CommandSender,
- private val packetHandler: PacketHandler,
+ private val heartbeatSender: DataLayerHeartbeatSender,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConfigFlowManager {
private val wantConfigDelay = 100L
@@ -90,10 +87,8 @@ class MeshConfigFlowManagerImpl(
* [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until
* `config_complete_id` arrives.
*/
- data class ReceivingNodeInfo(
- val myNodeInfo: SharedMyNodeInfo,
- val nodes: MutableList = mutableListOf(),
- ) : HandshakeState()
+ data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List = emptyList()) :
+ HandshakeState()
/** Both stages finished. The app is fully connected. */
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
@@ -139,28 +134,31 @@ class MeshConfigFlowManagerImpl(
return
}
+ // Warn if firmware is below the absolute minimum supported version.
+ // The UI layer already enforces this via FirmwareVersionCheck, so we just log here
+ // for diagnostics rather than hard-disconnecting.
+ finalizedInfo.firmwareVersion?.let { fwVersion ->
+ if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
+ Logger.w {
+ "Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " +
+ "protocol incompatibilities may occur"
+ }
+ }
+ }
+
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.value.onRadioConfigLoaded()
scope.handledLaunch {
delay(wantConfigDelay)
- sendHeartbeat()
+ heartbeatSender.sendHeartbeat("inter-stage")
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
connectionManager.value.startNodeInfoOnly()
}
}
- private fun sendHeartbeat() {
- try {
- packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat()))
- Logger.d { "Heartbeat sent between nonce stages" }
- } catch (ex: IOException) {
- Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" }
- }
- }
-
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
Logger.i { "NodeInfo complete (Stage 2)" }
@@ -168,16 +166,12 @@ class MeshConfigFlowManagerImpl(
// Transition state immediately (synchronously) to prevent duplicate handling.
// The async work below (DB writes, broadcasts) proceeds without the guard.
+ // Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot.
+ // Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored.
handshakeState = HandshakeState.Complete(myNodeInfo = info)
- // Snapshot and clear immediately so that a concurrent stall-guard retry (which
- // resends want_config_id and causes the firmware to restart the node_info burst)
- // starts accumulating into a fresh list rather than doubling this batch.
- val nodesToProcess = state.nodes.toList()
- state.nodes.clear()
-
val entities =
- nodesToProcess.mapNotNull { nodeInfo ->
+ state.nodes.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
@@ -242,7 +236,7 @@ class MeshConfigFlowManagerImpl(
override fun handleNodeInfo(info: NodeInfo) {
val state = handshakeState
if (state is HandshakeState.ReceivingNodeInfo) {
- state.nodes.add(info)
+ handshakeState = state.copy(nodes = state.nodes + info)
} else {
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
}
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 31e4f331d..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
@@ -84,6 +85,7 @@ class MeshConnectionManagerImpl(
private val packetRepository: PacketRepository,
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
+ private val heartbeatSender: DataLayerHeartbeatSender,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConnectionManager {
/**
@@ -92,6 +94,7 @@ class MeshConnectionManagerImpl(
*/
private val connectionMutex = Mutex()
+ private var preHandshakeJob: Job? = null
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
@@ -172,6 +175,8 @@ class MeshConnectionManagerImpl(
sleepTimeout?.cancel()
sleepTimeout = null
+ preHandshakeJob?.cancel()
+ preHandshakeJob = null
handshakeTimeout?.cancel()
handshakeTimeout = null
@@ -192,16 +197,26 @@ class MeshConnectionManagerImpl(
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
- Logger.i { "Starting mesh handshake (Stage 1)" }
connectTimeMsec = nowMillis
- startConfigOnly()
+
+ // Send a wake-up heartbeat before the config request. The firmware may be in a
+ // power-saving state where the NimBLE callback context needs warming up. The 100ms
+ // delay ensures the heartbeat BLE write is enqueued before the want_config_id
+ // (sendToRadio is fire-and-forget through async coroutine launches).
+ preHandshakeJob =
+ scope.handledLaunch {
+ heartbeatSender.sendHeartbeat("pre-handshake")
+ delay(PRE_HANDSHAKE_SETTLE_MS)
+ Logger.i { "Starting mesh handshake (Stage 1)" }
+ startConfigOnly()
+ }
}
- 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
@@ -277,19 +292,19 @@ 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()
}
override fun onRadioConfigLoaded() {
scope.handledLaunch {
- val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
+ val queuedPackets = packetRepository.getQueuedPackets()
queuedPackets.forEach { packet ->
try {
workerManager.enqueueSendMessage(packet.id)
@@ -381,7 +396,23 @@ class MeshConnectionManagerImpl(
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
private const val MAX_SLEEP_TIMEOUT_SECONDS = 300
- private val HANDSHAKE_TIMEOUT = 30.seconds
+ /**
+ * Delay between the pre-handshake heartbeat and the want_config_id send.
+ *
+ * Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the
+ * config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding
+ * negligible connection latency.
+ */
+ private const val PRE_HANDSHAKE_SETTLE_MS = 100L
+
+ 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 000d0b41d..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
@@ -96,7 +98,7 @@ class MeshMessageProcessorImpl(
}
.onFailure { _ ->
Logger.e(primaryException) {
- "Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord."
+ "Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord."
}
}
}
@@ -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/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index 338a0d6ea..fdcc6d344 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
@@ -98,7 +99,7 @@ class DeviceHardwareRepositoryImpl(
}
// 2. Fetch from remote API
- runCatching {
+ safeCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
@@ -157,7 +158,7 @@ class DeviceHardwareRepositoryImpl(
hwModel: Int,
target: String?,
quirks: List,
- ): Result = runCatching {
+ ): Result = safeCatching {
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
Logger.d {
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
index a47a5381f..8f3154815 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareRelease
@@ -97,7 +98,7 @@ open class FirmwareReleaseRepositoryImpl(
*/
private suspend fun updateCacheFromSources() {
val remoteFetchSuccess =
- runCatching {
+ safeCatching {
Logger.d { "Fetching fresh firmware releases from remote API." }
val networkReleases = remoteDataSource.getFirmwareReleases()
@@ -110,7 +111,7 @@ open class FirmwareReleaseRepositoryImpl(
// If remote fetch failed, try the JSON fallback as a last resort.
if (!remoteFetchSuccess) {
Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
- runCatching {
+ safeCatching {
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
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 f6a49f190..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,8 @@ 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
import org.meshtastic.core.model.ContactSettings
@@ -108,7 +110,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
dao.upsertContactSettings(listOf(updated))
}
- override suspend fun getQueuedPackets(): List? =
+ override suspend fun getQueuedPackets(): List =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
suspend fun insertRoomPacket(packet: RoomPacket) =
@@ -154,13 +156,14 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
else -> dao.getMessagesFrom(contact)
}
flow.mapLatest { packets ->
+ val cachedGetNode = memoize(getNode)
+ val replyIds = packets.mapNotNull { it.packet.data.replyId?.takeIf { id -> id != 0 } }.distinct()
+ val replyMap = batchGetPacketsByIds(replyIds)
packets.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage = replyId?.let { replyMap[it] }?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
}
@@ -177,13 +180,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
)
.flow
.map { pagingData ->
+ val cachedGetNode = memoize(getNode)
+ val replyCache = mutableMapOf()
pagingData.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage =
+ replyId
+ ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
+ ?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
@@ -204,13 +210,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
)
.flow
.map { pagingData ->
+ val cachedGetNode = memoize(getNode)
+ val replyCache = mutableMapOf()
pagingData.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage =
+ replyId
+ ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
+ ?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
@@ -230,6 +239,22 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
private suspend fun getPacketByPacketIdInternal(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) }
+ private suspend fun batchGetPacketsByIds(ids: List): Map = if (ids.isEmpty()) {
+ emptyMap()
+ } else {
+ withContext(dispatchers.io) {
+ val dao = dbManager.currentDb.value.packetDao()
+ ids.chunked(NodeInfoDao.MAX_BIND_PARAMS)
+ .flatMap { dao.getPacketsByPacketIds(it) }
+ .associateBy { it.packet.packetId }
+ }
+ }
+
+ private fun memoize(getNode: suspend (String?) -> Node): suspend (String?) -> Node {
+ val cache = mutableMapOf()
+ return { id -> cache.getOrPut(id) { getNode(id) } }
+ }
+
override suspend fun insert(
packet: DataPacket,
myNodeNum: Int,
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/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
index e05c6f20a..fdcd8ed44 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
@@ -97,7 +98,7 @@ class MeshConfigFlowManagerImplTest {
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
commandSender = commandSender,
- packetHandler = packetHandler,
+ heartbeatSender = DataLayerHeartbeatSender(packetHandler),
scope = testScope,
)
}
@@ -174,6 +175,49 @@ class MeshConfigFlowManagerImplTest {
verify { connectionManager.startNodeInfoOnly() }
}
+ @Test
+ fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
+
+ manager.handleMyInfo(protoMyNodeInfo)
+ advanceUntilIdle()
+ manager.handleLocalMetadata(metadata)
+ advanceUntilIdle()
+
+ sentPackets.clear() // Clear any packets from prior phases
+ manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
+ advanceUntilIdle()
+
+ val heartbeats = sentPackets.filter { it.heartbeat != null }
+ assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat")
+ assertEquals(
+ true,
+ heartbeats[0].heartbeat!!.nonce != 0,
+ "Inter-stage heartbeat should have a non-zero nonce",
+ )
+ }
+
+ @Test
+ fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest {
+ val oldMetadata =
+ DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
+ manager.handleMyInfo(protoMyNodeInfo)
+ advanceUntilIdle()
+ manager.handleLocalMetadata(oldMetadata)
+ advanceUntilIdle()
+
+ manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
+ advanceUntilIdle()
+
+ // Handshake should still progress despite old firmware
+ verify { connectionManager.onRadioConfigLoaded() }
+ verify { connectionManager.startNodeInfoOnly() }
+ }
+
@Test
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
index c6dfa7f43..07c8914ad 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
@@ -129,6 +129,7 @@ class MeshConnectionManagerImplTest {
packetRepository,
workerManager,
appWidgetUpdater,
+ DataLayerHeartbeatSender(packetHandler),
scope,
)
@@ -148,6 +149,59 @@ class MeshConnectionManagerImplTest {
verify { serviceBroadcasts.broadcastConnection() }
}
+ @Test
+ fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
+
+ manager = createManager(backgroundScope)
+ radioConnectionState.value = ConnectionState.Connected
+ // Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout
+ advanceTimeBy(200)
+
+ // First ToRadio should be a heartbeat, second should be want_config_id
+ assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets")
+ val heartbeat = sentPackets[0]
+ val wantConfig = sentPackets[1]
+
+ assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat")
+ assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce")
+ assertEquals(
+ org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE,
+ wantConfig.want_config_id,
+ "Second packet should be want_config_id with CONFIG_NONCE",
+ )
+ }
+
+ @Test
+ fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
+ every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
+
+ manager = createManager(backgroundScope)
+ radioConnectionState.value = ConnectionState.Connected
+ // Advance only 50ms — within the 100ms settle window
+ advanceTimeBy(50)
+
+ // Should have sent only the heartbeat so far, not want_config_id
+ assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes")
+
+ // Disconnect before the settle delay completes — should cancel the pending config start
+ radioConnectionState.value = ConnectionState.Disconnected
+ advanceTimeBy(200)
+
+ // The want_config_id should NOT have been sent because the job was cancelled
+ val configPackets = sentPackets.filter { it.want_config_id != null }
+ assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect")
+ }
+
@Test
fun `Disconnected state stops services`() = runTest(testDispatcher) {
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json
new file mode 100644
index 000000000..c26991ac4
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json
@@ -0,0 +1,1052 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 38,
+ "identityHash": "ffca7655fa7c1d69fdd404b1b39d140c",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffca7655fa7c1d69fdd404b1b39d140c')"
+ ]
+ }
+}
\ No newline at end of file
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/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
index c917ee066..b2c89ad73 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.database
import okio.ByteString.Companion.encodeUtf8
+import org.meshtastic.core.common.util.normalizeAddress
object DatabaseConstants {
const val DB_PREFIX: String = "meshtastic_database"
@@ -40,17 +41,6 @@ object DatabaseConstants {
const val ADDRESS_ANON_EDGE_LEN: Int = 2
}
-fun normalizeAddress(addr: String?): String {
- val u = addr?.trim()?.uppercase()
- val normalized =
- when {
- u.isNullOrBlank() -> "DEFAULT"
- u == "N" || u == "NULL" -> "DEFAULT"
- else -> u.replace(":", "")
- }
- return normalized
-}
-
fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN)
fun buildDbName(address: String?): String = if (address.isNullOrBlank()) {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index ba5887f95..108345265 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -241,6 +241,7 @@ open class DatabaseManager(
victims.forEach { name ->
runCatching {
+ // runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(name)
deleteDatabase(name)
datastore.edit { it.remove(lastUsedKey(name)) }
@@ -266,6 +267,7 @@ open class DatabaseManager(
if (fs.exists(legacyPath)) {
runCatching {
+ // runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(legacy)
deleteDatabase(legacy)
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 7bf9014ce..13451e5fc 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -94,8 +94,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class),
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
+ AutoMigration(from = 37, to = 38),
],
- version = 37,
+ version = 38,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
index fcdc079f2..c1e399c97 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
@@ -17,18 +17,15 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import org.meshtastic.core.database.entity.DeviceHardwareEntity
@Dao
interface DeviceHardwareDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(deviceHardware: DeviceHardwareEntity)
+ @Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity)
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertAll(deviceHardware: List)
+ @Upsert suspend fun insertAll(deviceHardware: List)
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): List
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
index 0a5520a07..040941a49 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
@@ -17,16 +17,14 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
@Dao
interface FirmwareReleaseDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
+ @Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Query("DELETE FROM firmware_release")
suspend fun deleteAll()
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
index 967a97ec5..35d29c161 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
@@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog
@Dao
interface MeshLogDao {
- @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem")
+ @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem")
fun getAllLogs(maxItem: Int): Flow>
- @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem")
+ @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem")
fun getAllLogsInReceiveOrder(maxItem: Int): Flow>
/**
@@ -40,7 +40,7 @@ interface MeshLogDao {
"""
SELECT * FROM log
WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum)
- ORDER BY received_date DESC LIMIT 0,:maxItem
+ ORDER BY received_date DESC LIMIT :maxItem
""",
)
fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow>
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
index e11d10f50..407a4d853 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
@@ -17,9 +17,7 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
import androidx.room3.MapColumn
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@@ -37,6 +35,9 @@ interface NodeInfoDao {
companion object {
const val KEY_SIZE = 32
+
+ /** SQLite has a limit of ~999 bind parameters per query. */
+ const val MAX_BIND_PARAMS = 999
}
/**
@@ -168,8 +169,7 @@ interface NodeInfoDao {
@Query("SELECT * FROM my_node")
fun getMyNodeInfo(): Flow
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
+ @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Query("DELETE FROM my_node")
suspend fun clearMyNodeInfo()
@@ -284,9 +284,15 @@ interface NodeInfoDao {
@Transaction
suspend fun getNodeByNum(num: Int): NodeWithRelations?
+ @Query("SELECT * FROM nodes WHERE num IN (:nodeNums)")
+ suspend fun getNodeEntitiesByNums(nodeNums: List): List
+
@Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1")
suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity?
+ @Query("SELECT * FROM nodes WHERE public_key IN (:publicKeys)")
+ suspend fun findNodesByPublicKeys(publicKeys: List): List
+
@Upsert suspend fun doUpsert(node: NodeEntity)
@Transaction
@@ -295,17 +301,82 @@ interface NodeInfoDao {
doUpsert(verifiedNode)
}
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun putAll(nodes: List)
+ @Upsert suspend fun putAll(nodes: List)
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
suspend fun setNodeNotes(num: Int, notes: String)
+ /**
+ * Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two
+ * queries instead of N individual queries, then processes each node in memory.
+ */
+ @Suppress("NestedBlockDepth")
+ private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List {
+ // Prepare all incoming nodes (populate denormalized fields)
+ incomingNodes.forEach { node ->
+ node.publicKey = node.user.public_key
+ if (node.user.hw_model != HardwareModel.UNSET) {
+ node.longName = node.user.long_name
+ node.shortName = node.user.short_name
+ } else {
+ node.longName = null
+ node.shortName = null
+ }
+ }
+
+ // Batch fetch all existing nodes by num (chunked for SQLite bind-param limit)
+ val existingNodesMap =
+ incomingNodes
+ .map { it.num }
+ .chunked(MAX_BIND_PARAMS)
+ .flatMap { getNodeEntitiesByNums(it) }
+ .associateBy { it.num }
+
+ // Partition into updates vs. inserts and resolve existing nodes in-memory
+ val result = mutableListOf()
+ val newNodes = mutableListOf()
+ for (incoming in incomingNodes) {
+ val existing = existingNodesMap[incoming.num]
+ if (existing != null) {
+ result.add(handleExistingNodeUpsertValidation(existing, incoming))
+ } else {
+ newNodes.add(incoming)
+ }
+ }
+
+ // Batch validate new nodes' public keys (one query instead of N)
+ val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct()
+ val pkConflicts =
+ if (publicKeysToCheck.isNotEmpty()) {
+ publicKeysToCheck
+ .chunked(MAX_BIND_PARAMS)
+ .flatMap { findNodesByPublicKeys(it) }
+ .associateBy { it.publicKey }
+ } else {
+ emptyMap()
+ }
+
+ for (newNode in newNodes) {
+ if ((newNode.publicKey?.size ?: 0) > 0) {
+ val conflicting = pkConflicts[newNode.publicKey]
+ if (conflicting != null && conflicting.num != newNode.num) {
+ result.add(conflicting)
+ } else {
+ result.add(newNode)
+ }
+ } else {
+ result.add(newNode)
+ }
+ }
+
+ return result
+ }
+
@Transaction
suspend fun installConfig(mi: MyNodeEntity, nodes: List) {
clearMyNodeInfo()
setMyNodeInfo(mi)
- putAll(nodes.map { getVerifiedNodeForUpsert(it) })
+ putAll(getVerifiedNodesForUpsert(nodes))
}
/**
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index 1419d51e7..c2ef9c516 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -18,7 +18,9 @@ package org.meshtastic.core.database.dao
import androidx.paging.PagingSource
import androidx.room3.Dao
+import androidx.room3.Insert
import androidx.room3.MapColumn
+import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Update
@@ -307,6 +309,16 @@ interface PacketDao {
)
suspend fun getPacketByPacketId(packetId: Int): PacketEntity?
+ @Transaction
+ @Query(
+ """
+ SELECT * FROM packet
+ WHERE packet_id IN (:packetIds)
+ AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ """,
+ )
+ suspend fun getPacketsByPacketIds(packetIds: List): List
+
@Query(
"""
SELECT * FROM packet
@@ -326,8 +338,15 @@ interface PacketDao {
)
suspend fun findPacketBySfppHash(hash: ByteString): Packet?
- @Transaction
- suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
+ @Query(
+ """
+ SELECT data FROM packet
+ WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ AND json_extract(data, '${"$"}.status') = 'QUEUED'
+ ORDER BY received_time ASC
+ """,
+ )
+ suspend fun getQueuedPackets(): List
@Query(
"""
@@ -359,23 +378,24 @@ interface PacketDao {
@Upsert suspend fun upsertContactSettings(contacts: List)
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertContactSettingsIgnore(contacts: List)
+
+ @Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)")
+ suspend fun updateMuteUntil(contactKeys: List, muteUntil: Long)
+
@Transaction
suspend fun setMuteUntil(contacts: List, until: Long) {
- val contactList = contacts.map { contact ->
- // Always mute
- val absoluteMuteUntil =
- if (until == Long.MAX_VALUE) {
- Long.MAX_VALUE
- } else if (until == 0L) { // unmute
- 0L
- } else {
- nowMillis + until
- }
-
- getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil)
- ?: ContactSettings(contact_key = contact, muteUntil = absoluteMuteUntil)
- }
- upsertContactSettings(contactList)
+ val absoluteMuteUntil =
+ when {
+ until == Long.MAX_VALUE -> Long.MAX_VALUE
+ until == 0L -> 0L
+ else -> nowMillis + until
+ }
+ // Ensure rows exist for all contacts (IGNORE avoids overwriting existing data)
+ insertContactSettingsIgnore(contacts.map { ContactSettings(contact_key = it) })
+ // Atomic column-level update — no read-then-write race
+ updateMuteUntil(contacts, absoluteMuteUntil)
}
@Upsert suspend fun insert(reaction: ReactionEntity)
@@ -479,9 +499,10 @@ interface PacketDao {
val indexMap =
oldSettings
.mapIndexed { oldIndex, oldChannel ->
- val pskMatches = newSettings.mapIndexedNotNull { index, channel ->
- if (channel.psk == oldChannel.psk) index to channel else null
- }
+ val pskMatches =
+ newSettings.mapIndexedNotNull { index, channel ->
+ if (channel.psk == oldChannel.psk) index to channel else null
+ }
val newIndex =
when {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
index 2e7f6c549..fde388ce5 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
@@ -17,9 +17,8 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
@@ -32,6 +31,5 @@ interface TracerouteNodePositionDao {
@Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid")
suspend fun deleteByLogUuid(logUuid: String)
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertAll(entities: List)
+ @Upsert suspend fun insertAll(entities: List)
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
index 13d10193c..fed88eef9 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
@@ -118,6 +118,7 @@ data class MetadataEntity(
Index(value = ["hops_away"]),
Index(value = ["is_favorite"]),
Index(value = ["last_heard", "is_favorite"]),
+ Index(value = ["public_key"]),
],
)
data class NodeEntity(
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 16b1e66e4..d01171751 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -74,6 +74,9 @@ data class PacketEntity(
Index(value = ["contact_key"]),
Index(value = ["contact_key", "port_num", "received_time"]),
Index(value = ["packet_id"]),
+ Index(value = ["received_time"]),
+ Index(value = ["filtered"]),
+ Index(value = ["read"]),
],
)
data class Packet(
@@ -98,9 +101,12 @@ data class Packet(
fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
- val candidateRelayNodes = nodes.filter {
- it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
- }
+ val candidateRelayNodes =
+ nodes.filter {
+ it.num != ourNodeNum &&
+ it.lastHeard != 0 &&
+ (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
+ }
val closestRelayNode =
if (candidateRelayNodes.size == 1) {
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/datastore/build.gradle.kts b/core/datastore/build.gradle.kts
index 903dde119..7d46cc831 100644
--- a/core/datastore/build.gradle.kts
+++ b/core/datastore/build.gradle.kts
@@ -24,7 +24,11 @@ plugins {
kotlin {
jvm()
- android { namespace = "org.meshtastic.core.datastore" }
+ android {
+ namespace = "org.meshtastic.core.datastore"
+ androidResources.enable = false
+ withHostTest {}
+ }
sourceSets {
commonMain.dependencies {
@@ -36,5 +40,11 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
}
+
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.okio)
+ }
}
}
diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
index 94ef1c605..9de792a84 100644
--- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
+++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
@@ -50,7 +50,7 @@ class PreferencesDataStoreModule {
@Named("CorePreferencesDataStore")
fun providePreferencesDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
@@ -66,7 +66,7 @@ class LocalConfigDataStoreModule {
@Named("CoreLocalConfigDataStore")
fun provideLocalConfigDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule {
@Named("CoreModuleConfigDataStore")
fun provideModuleConfigDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -104,7 +104,7 @@ class ChannelSetDataStoreModule {
@Named("CoreChannelSetDataStore")
fun provideChannelSetDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -123,7 +123,7 @@ class LocalStatsDataStoreModule {
@Named("CoreLocalStatsDataStore")
fun provideLocalStatsDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
index aa81f1ac6..3cb3cabe8 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
@@ -24,10 +24,17 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
+/**
+ * Koin qualifier for the application-scoped [CoroutineScope] shared by all [DataStore] instances.
+ *
+ * Used with `@Named(DATASTORE_SCOPE)` in Koin annotations and `named(DATASTORE_SCOPE)` in manual DSL modules.
+ */
+const val DATASTORE_SCOPE = "DataStoreScope"
+
@Module
@ComponentScan("org.meshtastic.core.datastore")
class CoreDatastoreModule {
@Single
- @Named("DataStoreScope")
+ @Named(DATASTORE_SCOPE)
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
}
diff --git a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
new file mode 100644
index 000000000..3acd29cb9
--- /dev/null
+++ b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
@@ -0,0 +1,286 @@
+/*
+ * 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.datastore
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonPrimitive
+import okio.FileSystem
+import okio.Path
+import org.meshtastic.core.datastore.model.RecentAddress
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+@OptIn(ExperimentalUuidApi::class)
+class RecentAddressesDataSourceTest {
+ private lateinit var tmpDir: Path
+ private lateinit var dataSource: RecentAddressesDataSource
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+
+ @BeforeTest
+ fun setup() {
+ tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "recentAddressesTest-${Uuid.random()}"
+ FileSystem.SYSTEM.createDirectories(tmpDir)
+ val dataStore =
+ PreferenceDataStoreFactory.createWithPath(
+ scope = testScope,
+ produceFile = { tmpDir / "test.preferences_pb" },
+ )
+ dataSource = RecentAddressesDataSource(dataStore)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ FileSystem.SYSTEM.deleteRecursively(tmpDir)
+ }
+
+ // ---- recentAddresses flow ----
+
+ @Test
+ fun `recentAddresses emits empty list when no data stored`() = testScope.runTest {
+ val result = dataSource.recentAddresses.first()
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `setRecentAddresses persists and emits the list`() = testScope.runTest {
+ val addresses =
+ listOf(
+ RecentAddress(address = "192.168.1.1", name = "Home"),
+ RecentAddress(address = "10.0.0.1", name = "Office"),
+ )
+ dataSource.setRecentAddresses(addresses)
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(addresses, result)
+ }
+
+ @Test
+ fun `setRecentAddresses overwrites previous value`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.2.3.4", "Old")))
+ dataSource.setRecentAddresses(listOf(RecentAddress("5.6.7.8", "New")))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(1, result.size)
+ assertEquals("5.6.7.8", result[0].address)
+ }
+
+ // ---- add() LRU behaviour ----
+
+ @Test
+ fun `add to empty list stores single entry`() = testScope.runTest {
+ dataSource.add(RecentAddress("192.168.0.1", "Router"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(1, result.size)
+ assertEquals("192.168.0.1", result[0].address)
+ }
+
+ @Test
+ fun `add prepends new address to front`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "Existing")))
+ dataSource.add(RecentAddress("2.2.2.2", "New"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals("2.2.2.2", result[0].address)
+ assertEquals("1.1.1.1", result[1].address)
+ }
+
+ @Test
+ fun `add deduplicates by address moving existing entry to front with updated name`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "First"), RecentAddress("2.2.2.2", "Second")))
+ dataSource.add(RecentAddress("2.2.2.2", "Second-updated"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(2, result.size)
+ assertEquals("2.2.2.2", result[0].address)
+ assertEquals("Second-updated", result[0].name)
+ assertEquals("1.1.1.1", result[1].address)
+ }
+
+ @Test
+ fun `add enforces CACHE_CAPACITY of 3 evicting oldest entry`() = testScope.runTest {
+ dataSource.setRecentAddresses(
+ listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")),
+ )
+ dataSource.add(RecentAddress("4.4.4.4", "D"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(3, result.size)
+ assertEquals("4.4.4.4", result[0].address)
+ assertEquals("1.1.1.1", result[1].address)
+ assertEquals("2.2.2.2", result[2].address)
+ assertFalse(result.any { it.address == "3.3.3.3" })
+ }
+
+ @Test
+ fun `add re-adding the same address at front keeps capacity`() = testScope.runTest {
+ dataSource.setRecentAddresses(
+ listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")),
+ )
+ dataSource.add(RecentAddress("1.1.1.1", "A"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(3, result.size)
+ assertEquals("1.1.1.1", result[0].address)
+ }
+
+ // ---- remove() ----
+
+ @Test
+ fun `remove deletes the matching address`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B")))
+ dataSource.remove("1.1.1.1")
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(1, result.size)
+ assertEquals("2.2.2.2", result[0].address)
+ }
+
+ @Test
+ fun `remove on unknown address is a no-op`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A")))
+ dataSource.remove("9.9.9.9")
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(1, result.size)
+ }
+
+ @Test
+ fun `remove last address yields empty list`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A")))
+ dataSource.remove("1.1.1.1")
+
+ assertTrue(dataSource.recentAddresses.first().isEmpty())
+ }
+
+ // ---- legacy JSON parsing (via LegacyParsingHarness) ----
+
+ @Test
+ fun `legacy JsonObject array is parsed correctly`() = testScope.runTest {
+ val legacyJson =
+ """[{"address":"192.168.1.100","name":"NodeA"},{"address":"192.168.1.101","name":"NodeB"}]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(2, result.size)
+ assertEquals("192.168.1.100", result[0].address)
+ assertEquals("NodeA", result[0].name)
+ assertEquals("192.168.1.101", result[1].address)
+ assertEquals("NodeB", result[1].name)
+ }
+
+ @Test
+ fun `legacy bare string JsonPrimitive array is parsed correctly`() = testScope.runTest {
+ // Old clients stored plain IP strings with no name field
+ val legacyJson = """["192.168.1.50","10.0.0.2"]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(2, result.size)
+ assertEquals("192.168.1.50", result[0].address)
+ assertEquals("Meshtastic", result[0].name)
+ assertEquals("10.0.0.2", result[1].address)
+ assertEquals("Meshtastic", result[1].name)
+ }
+
+ @Test
+ fun `legacy JsonObject missing address field is skipped`() = testScope.runTest {
+ val legacyJson = """[{"name":"NoAddress"},{"address":"1.2.3.4","name":"Good"}]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(1, result.size)
+ assertEquals("1.2.3.4", result[0].address)
+ }
+
+ @Test
+ fun `legacy JsonObject missing name field is skipped`() = testScope.runTest {
+ val legacyJson = """[{"address":"1.2.3.4"},{"address":"5.6.7.8","name":"Good"}]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(1, result.size)
+ assertEquals("5.6.7.8", result[0].address)
+ }
+
+ @Test
+ fun `legacy nested JsonArray entries are skipped`() = testScope.runTest {
+ val legacyJson = """[["nested","array"],{"address":"1.2.3.4","name":"Good"}]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(1, result.size)
+ assertEquals("1.2.3.4", result[0].address)
+ }
+
+ @Test
+ fun `legacy mixed array handles all element types`() = testScope.runTest {
+ // JsonPrimitive + valid JsonObject + malformed JsonObject + nested JsonArray
+ val legacyJson = """["10.0.0.1",{"address":"10.0.0.2","name":"Node"},{"name":"bad"},[1,2]]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(2, result.size)
+ assertEquals("10.0.0.1", result[0].address)
+ assertEquals("Meshtastic", result[0].name)
+ assertEquals("10.0.0.2", result[1].address)
+ }
+}
+
+/**
+ * Test harness that mirrors the private legacy parsing logic of [RecentAddressesDataSource] without needing to bypass
+ * encapsulation. Exposes a [Flow] that emits the result of parsing a raw legacy JSON string using the same rules as the
+ * production fallback path.
+ */
+private class LegacyParsingHarness(private val rawJson: String) {
+ val recentAddresses: Flow> = flow {
+ val jsonArray = Json.parseToJsonElement(rawJson).jsonArray
+ emit(
+ jsonArray.mapNotNull { item ->
+ when (item) {
+ is JsonObject -> {
+ val address = item["address"]?.jsonPrimitive?.contentOrNull
+ val name = item["name"]?.jsonPrimitive?.contentOrNull
+ if (address != null && name != null) {
+ RecentAddress(address = address, name = name)
+ } else {
+ null
+ }
+ }
+ is JsonPrimitive -> {
+ item.contentOrNull?.let { RecentAddress(address = it, name = "Meshtastic") }
+ }
+ is JsonArray -> null
+ }
+ },
+ )
+ }
+}
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
similarity index 69%
rename from feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt
rename to core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
index 0a35599f5..fa708d165 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
@@ -14,9 +14,14 @@
* 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.core.domain.usecase.settings
-import org.meshtastic.core.navigation.SettingsRoute
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.UiPrefs
-actual fun getAboutLibrariesJson(): String =
- SettingsRoute::class.java.getResource("/aboutlibraries.json")?.readText() ?: ""
+@Single
+open class SetContrastLevelUseCase constructor(private val uiPrefs: UiPrefs) {
+ operator fun invoke(value: Int) {
+ uiPrefs.setContrastLevel(value)
+ }
+}
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/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
deleted file mode 100644
index 473e482e2..000000000
--- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.core.model.util
-
-import org.meshtastic.core.common.util.nowInstant
-import org.meshtastic.core.common.util.toDate
-import org.meshtastic.core.common.util.toInstant
-import java.text.DateFormat
-import kotlin.time.Duration.Companion.hours
-
-private val DAY_DURATION = 24.hours
-
-/**
- * Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string
- * representing the date.
- *
- * @param time The time in milliseconds
- * @return Formatted date or time string, or null if time is 0
- */
-fun getShortDate(time: Long): String? {
- if (time == 0L) return null
- val instant = time.toInstant()
- val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
-
- return if (isWithin24Hours) {
- DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
- } else {
- DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate())
- }
-}
-
-/**
- * Calculates the remaining mute time in days and hours.
- *
- * @param remainingMillis The remaining time in milliseconds
- * @return Pair of (days, hours), where days is Int and hours is Double
- */
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
index 13b0789de..99debb5ab 100644
--- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
@@ -17,12 +17,13 @@
package org.meshtastic.core.model.util
import android.net.Uri
+import com.eygraber.uri.toKmpUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
/** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */
-fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString())
+fun Uri.toCommonUri(): CommonUri = this.toKmpUri()
/** Bridge extension for Android clients. */
fun Uri.dispatchMeshtasticUri(
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/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
index 13eccae2a..70dea8574 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
@@ -19,10 +19,9 @@ package org.meshtastic.core.model
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.GPSFormat
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.bearing
-import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.latLongToMeter
-import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.Config
@@ -143,34 +142,26 @@ data class Node(
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List {
val temp =
if ((temperature ?: 0f) != 0f) {
- if (isFahrenheit) {
- formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f))
- } else {
- formatString("%.1f°C", temperature)
- }
+ MetricFormatter.temperature(temperature ?: 0f, isFahrenheit)
} else {
null
}
- val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null
+ val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
- if (isFahrenheit) {
- formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f))
- } else {
- formatString("%.1f°C", soil_temperature)
- }
+ MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit)
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
- formatString("%d%%", soil_moisture)
+ MetricFormatter.percent(soil_moisture ?: 0)
} else {
null
}
- val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null
- val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null
+ val voltage = if ((this.voltage ?: 0f) != 0f) MetricFormatter.voltage(this.voltage ?: 0f) else null
+ val current = if ((current ?: 0f) != 0f) MetricFormatter.current(current ?: 0f) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
@@ -199,9 +190,12 @@ data class Node(
fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
- val candidateRelayNodes = nodes.filter {
- it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
- }
+ val candidateRelayNodes =
+ nodes.filter {
+ it.num != ourNodeNum &&
+ it.lastHeard != 0 &&
+ (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
+ }
val closestRelayNode =
if (candidateRelayNodes.size == 1) {
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 6f27bb0e6..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
/**
@@ -32,7 +35,7 @@ val Any?.anonymize: String
get() = this.anonymize()
/** A version of anonymize that allows passing in a custom minimum length */
-fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null"
+fun Any?.anonymize(maxLen: Int = 3) = if (this != null) "...${this.toString().takeLast(maxLen)}" else "null"
// A toString that makes sure all newlines are removed (for nice logging).
fun Any.toOneLineString() = this.toString().replace('\n', ' ')
@@ -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/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
index ca035a7fd..ebdcc0f5e 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
@@ -16,7 +16,27 @@
*/
package org.meshtastic.core.model.util
+import okio.ByteString.Companion.toByteString
+
/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */
-expect object SfppHasher {
- fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray
+object SfppHasher {
+ private const val HASH_SIZE = 16
+ private const val INT_BYTES = 4
+ private const val INT_COUNT = 3
+ private const val SHIFT_8 = 8
+ private const val SHIFT_16 = 16
+ private const val SHIFT_24 = 24
+
+ fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
+ val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT)
+ encryptedPayload.copyInto(input)
+ var offset = encryptedPayload.size
+ for (value in intArrayOf(to, from, id)) {
+ input[offset++] = value.toByte()
+ input[offset++] = (value shr SHIFT_8).toByte()
+ input[offset++] = (value shr SHIFT_16).toByte()
+ input[offset++] = (value shr SHIFT_24).toByte()
+ }
+ return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE)
+ }
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
index b2e175382..4b3f5d149 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
@@ -107,7 +107,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
return if (changes.isEmpty()) {
"No changes detected."
} else {
- "Changes:\n" + changes.joinToString("\n")
+ "Changes:\n${changes.joinToString("\n")}"
}
}
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
similarity index 95%
rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt
rename to core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
index 51f6a5c76..14dfd72c8 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
@@ -14,12 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.common
+package org.meshtastic.core.model.util
import kotlin.test.Test
import kotlin.test.assertEquals
-class ByteUtilsTest {
+class CommonUtilsTest {
@Test
fun testByteArrayOfInts() {
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
new file mode 100644
index 000000000..917414e3d
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+class SfppHasherTest {
+
+ @Test
+ fun outputIsAlways16Bytes() {
+ val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1)
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun emptyPayloadProduces16Bytes() {
+ val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0)
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun deterministicOutput() {
+ val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
+ assertEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentPayloadsProduceDifferentHashes() {
+ val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentIdsProduceDifferentHashes() {
+ val payload = byteArrayOf(0x10, 0x20)
+ val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100)
+ val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentFromProduceDifferentHashes() {
+ val payload = byteArrayOf(0x10, 0x20)
+ val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun maxIntValues() {
+ val hash =
+ SfppHasher.computeMessageHash(
+ byteArrayOf(0xFF.toByte()),
+ to = Int.MAX_VALUE,
+ from = Int.MAX_VALUE,
+ id = Int.MAX_VALUE,
+ )
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun littleEndianByteOrder() {
+ // Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian)
+ val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0)
+ val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0)
+ // Different byte orderings must produce different hashes
+ assertNotEquals(hashA.toList(), hashB.toList())
+ }
+}
diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
index 7545a00a7..d17abd4a3 100644
--- a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
+++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
@@ -20,7 +20,3 @@ package org.meshtastic.core.model.util
actual fun getShortDateTime(time: Long): String = ""
actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
-
-actual object SfppHasher {
- actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32)
-}
diff --git a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
deleted file mode 100644
index b1c25110b..000000000
--- a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.core.model.util
-
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-import java.security.MessageDigest
-
-actual object SfppHasher {
- private const val HASH_SIZE = 16
- private const val INT_BYTES = 4
-
- actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
- val digest = MessageDigest.getInstance("SHA-256")
- digest.update(encryptedPayload)
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array())
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array())
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array())
- return digest.digest().copyOf(HASH_SIZE)
- }
-}
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/TrustAllX509TrustManager.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt
deleted file mode 100644
index 720d2a522..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.core.network.repository
-
-import android.annotation.SuppressLint
-import java.security.cert.X509Certificate
-import javax.net.ssl.X509TrustManager
-
-@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
-@Suppress("EmptyFunctionBlock")
-class TrustAllX509TrustManager : X509TrustManager {
- override fun checkClientTrusted(chain: Array?, authType: String?) {}
-
- override fun checkServerTrusted(chain: Array?, authType: String?) {}
-
- override fun getAcceptedIssuers(): Array = arrayOf()
-}
diff --git a/core/network/src/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/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
new file mode 100644
index 000000000..87c317024
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.network
+
+/**
+ * Shared HTTP client configuration constants used by both Android and Desktop Ktor `HttpClient` setups.
+ *
+ * These values are consumed by the platform-specific Koin modules (`NetworkModule` on Android, `DesktopKoinModule` on
+ * Desktop) when installing [io.ktor.client.plugins.HttpTimeout] and [io.ktor.client.plugins.HttpRequestRetry].
+ */
+object HttpClientDefaults {
+ /** Timeout in milliseconds for connect, request, and socket operations. */
+ const val TIMEOUT_MS = 30_000L
+
+ /** Maximum number of automatic retries on server errors (5xx). */
+ const val MAX_RETRIES = 3
+
+ /** Base URL for the Meshtastic public API. Installed via the `DefaultRequest` plugin. */
+ const val API_BASE_URL = "https://api.meshtastic.org/"
+}
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 78d3d4ceb..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" }
}
@@ -326,8 +326,8 @@ class MockRadioTransport(
user =
User(
id = DataPacket.nodeNumToDefaultId(numIn),
- long_name = "Sim " + numIn.toString(16),
- short_name = getInitials("Sim " + numIn.toString(16)),
+ long_name = "Sim ${numIn.toString(16)}",
+ short_name = getInitials("Sim ${numIn.toString(16)}"),
hw_model = HardwareModel.ANDROID_SIM,
),
position =
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/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
index ed7461058..6c15478d9 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
@@ -35,14 +35,14 @@ interface ApiService {
/**
* Ktor-based [ApiService] implementation.
*
+ * Uses relative paths — the base URL is set via the `DefaultRequest` plugin in the platform Koin modules.
+ *
* Registered with `binds = []` to prevent Koin from auto-binding to [ApiService]; host modules (`app`, `desktop`)
* provide their own explicit `ApiService` binding to allow platform-specific `HttpClient` engines.
*/
@Single(binds = [])
class ApiServiceImpl(private val client: HttpClient) : ApiService {
- override suspend fun getDeviceHardware(): List =
- client.get("https://api.meshtastic.org/resource/deviceHardware").body()
+ override suspend fun getDeviceHardware(): List = client.get("resource/deviceHardware").body()
- override suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
- client.get("https://api.meshtastic.org/github/firmware/list").body()
+ override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
}
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/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt
index 1b46232bf..34b9e49a3 100644
--- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt
@@ -17,12 +17,12 @@
package org.meshtastic.core.network.repository
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface
@@ -31,7 +31,7 @@ import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceListener
@Single
-class JvmServiceDiscovery : ServiceDiscovery {
+class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : ServiceDiscovery {
@Suppress("TooGenericExceptionCaught")
override val resolvedServices: Flow> =
callbackFlow {
@@ -98,7 +98,7 @@ class JvmServiceDiscovery : ServiceDiscovery {
}
}
}
- .flowOn(Dispatchers.IO)
+ .flowOn(dispatchers.io)
companion object {
/**
diff --git a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt
index e03076f39..5884daaaf 100644
--- a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt
+++ b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt
@@ -17,16 +17,23 @@
package org.meshtastic.core.network.repository
import app.cash.turbine.test
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.di.CoroutineDispatchers
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class JvmServiceDiscoveryTest {
+ private val testDispatchers =
+ UnconfinedTestDispatcher().let { dispatcher ->
+ CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
+ }
+
@Test
fun `resolvedServices emits initial empty list immediately`() = runTest {
- val discovery = JvmServiceDiscovery()
+ val discovery = JvmServiceDiscovery(testDispatchers)
discovery.resolvedServices.test {
val first = awaitItem()
assertNotNull(first, "First emission should not be null")
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt
index 5395ce723..d6c85d266 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt
@@ -19,19 +19,31 @@ package org.meshtastic.core.prefs
import kotlinx.atomicfu.AtomicRef
import kotlinx.collections.immutable.PersistentMap
-internal inline fun cachedFlow(cache: AtomicRef>, key: K, build: () -> V): V {
- var resolved = cache.value[key]
- if (resolved == null) {
- val newValue = build()
- while (resolved == null) {
- val current = cache.value
- val currentValue = current[key]
- if (currentValue != null) {
- resolved = currentValue
- } else if (cache.compareAndSet(current, current.put(key, newValue))) {
- resolved = newValue
- }
+/**
+ * Look up [key] in [cache]; if absent, construct a value via [build] and insert it atomically.
+ *
+ * [build] is wrapped in a [Lazy] before being published to [cache], so concurrent first-access of the same key never
+ * invokes [build] more than once — only the winner of the CAS has its [Lazy] evaluated, and all readers share that same
+ * result. This matters when [build] eagerly launches a coroutine (e.g. `Flow.stateIn(scope, Eagerly, …)`): the naive
+ * approach would leak the losing coroutine into a never-cancelled scope.
+ */
+@Suppress("ReturnCount")
+internal inline fun cachedFlow(
+ cache: AtomicRef>>,
+ key: K,
+ crossinline build: () -> V,
+): V {
+ cache.value[key]?.let {
+ return it.value
+ }
+ val newLazy = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { build() }
+ while (true) {
+ val current = cache.value
+ current[key]?.let {
+ return it.value
+ }
+ if (cache.compareAndSet(current, current.put(key, newLazy))) {
+ return newLazy.value
}
}
- return checkNotNull(resolved)
}
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt
index 763c81120..c43d4b2bb 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt
@@ -42,7 +42,7 @@ class MapConsentPrefsImpl(
) : MapConsentPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
- private val consentFlows = atomic(persistentMapOf>())
+ private val consentFlows = atomic(persistentMapOf>>())
override fun shouldReportLocation(nodeNum: Int?): StateFlow = cachedFlow(consentFlows, nodeNum) {
val key = booleanPreferencesKey(nodeNum.toString())
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt
index ad982e6a6..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
@@ -33,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.normalizeAddress
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.cachedFlow
import org.meshtastic.core.repository.MeshPrefs
@@ -44,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
@@ -64,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)
@@ -91,19 +81,8 @@ class MeshPrefsImpl(
}
}
- private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum"
-
private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}"
- private fun normalizeAddress(address: String?): String {
- val raw = address?.trim()?.takeIf { it.isNotEmpty() }
- return when {
- raw == null -> "DEFAULT"
- raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT"
- else -> raw.uppercase().replace(":", "")
- }
- }
-
companion object {
val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address")
}
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 33f688389..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)
@@ -62,6 +62,13 @@ class UiPrefsImpl(
scope.launch { dataStore.edit { it[KEY_THEME] = value } }
}
+ override val contrastLevel: StateFlow =
+ dataStore.data.map { it[KEY_CONTRAST_LEVEL] ?: 0 }.stateIn(scope, SharingStarted.Lazily, 0)
+
+ override fun setContrastLevel(value: Int) {
+ scope.launch { dataStore.edit { it[KEY_CONTRAST_LEVEL] = value } }
+ }
+
override val locale: StateFlow =
dataStore.data.map { it[KEY_LOCALE] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "")
@@ -152,6 +159,7 @@ class UiPrefsImpl(
val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed")
val KEY_THEME = intPreferencesKey("theme")
+ val KEY_CONTRAST_LEVEL = intPreferencesKey("contrast-level")
val KEY_LOCALE = stringPreferencesKey("locale")
val KEY_NODE_SORT = intPreferencesKey("node-sort-option")
val KEY_INCLUDE_UNKNOWN = booleanPreferencesKey("include-unknown")
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 a4c649bd3..4d5b500df 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit a4c649bd3e877dab9011d9e32dc778640ec22852
+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 f5203e3c1..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
@@ -80,6 +80,10 @@ interface UiPrefs {
fun setTheme(value: Int)
+ val contrastLevel: StateFlow
+
+ fun setContrastLevel(value: Int)
+
val locale: StateFlow
fun setLocale(languageTag: String)
@@ -209,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/FileService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt
index dca2a6bf3..9f7cbe0dd 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt
@@ -18,7 +18,7 @@ package org.meshtastic.core.repository
import okio.BufferedSink
import okio.BufferedSource
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
/**
* Abstracts file system operations (like reading from or writing to URIs) so that ViewModels can remain
@@ -29,11 +29,11 @@ interface FileService {
* Opens a file or URI for writing and provides a [BufferedSink]. The sink is automatically closed after [block]
* execution. Returns true if successful, false otherwise.
*/
- suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean
+ suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean
/**
* Opens a file or URI for reading and provides a [BufferedSource]. The source is automatically closed after [block]
* execution. Returns true if successful, false otherwise.
*/
- suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean
+ suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean
}
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/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
index a0977c582..6bd33a4cf 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
@@ -71,7 +71,7 @@ interface PacketRepository {
suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long)
/** Returns all packets currently queued for transmission. */
- suspend fun getQueuedPackets(): List?
+ suspend fun getQueuedPackets(): List
/**
* Persists a packet in the database.
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/build.gradle.kts b/core/resources/build.gradle.kts
index a1ba8fd63..966ab949a 100644
--- a/core/resources/build.gradle.kts
+++ b/core/resources/build.gradle.kts
@@ -25,7 +25,10 @@ kotlin {
@Suppress("UnstableApiUsage")
android {
- androidResources.enable = true
+ androidResources {
+ enable = true
+ resourcePrefix = "meshtastic_"
+ }
withHostTest { isIncludeAndroidResources = true }
}
diff --git a/core/resources/src/androidMain/res/raw/alert.mp3 b/core/resources/src/androidMain/res/raw/meshtastic_alert.mp3
similarity index 100%
rename from core/resources/src/androidMain/res/raw/alert.mp3
rename to core/resources/src/androidMain/res/raw/meshtastic_alert.mp3
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 ff2ceced6..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 @@
Възстановяване на настройките по подразбиранеПриложиТема
+ КонтрастСветлаТъмнаПо подразбиране на систематаИзбор на тема
+ Ниво на контраста
+ Стандартен
+ Среден
+ ВисокИзпращане на местоположение в мрежатаКомпактно кодиране за Кирилица
@@ -250,6 +255,7 @@
Съобщението е доставеноУстройството ви може да прекъсне връзката и да се рестартира, докато се прилагат настройките.Грешка
+ Неизвестна грешкаИгнорирайПремахване от игнорираниДобави '%1$s' към списъка с игнорирани?
@@ -300,9 +306,9 @@
БатерияИзползване на каналаИзползване на ефира
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s V
+ %1$s%1$s: %2$sзаписаБрой отскоци
@@ -318,12 +324,9 @@
Публичният ключ не съвпада със записания ключ. Можете да премахнете възела и да го оставите да обмени ключове отново, но това може да показва по-сериозен проблем със сигурността. Свържете се с потребителя чрез друг надежден канал, за да определите дали промяната на ключа се дължи на фабрично нулиране или друго умишлено действие.Известия за нови възлиSNR
- Съотношение сигнал/шум, мярка, използвана в комуникациите за количествено определяне на нивото на желания сигнал спрямо нивото на фоновия шум. В Meshtastic и други безжични системи, по-високото съотношение сигнал/шум показва по-ясен сигнал, който може да подобри надеждността и качеството на предаване на данни.RSSI
- Индикатор за силата на получения сигнал - измерване, използвано за определяне на нивото на получения сигнал, приемано от антената. По-високата стойност на RSSI обикновено показва по-силна и по-стабилна връзка.(Качество на въздуха в помещенията) относителна скала за IAQ, стойностите са измерени с Bosch BME680. Диапазон на стойностите 0–500.Метрики на устройството
- Карта на възелаПозицияПоследна актуализация на позициятаПоказатели на околната среда
@@ -363,7 +366,6 @@
1ММаксМин
- СрРазгъване на диаграматаСвиване на диаграматаНеизвестна възраст
@@ -468,6 +470,9 @@
Известия при получаване на сигнал/позвъняванеИзползване на PWM зумерТон на звънене
+ Импортирана мелодия
+ Файлът е празен
+ Грешка при импортиране: %1$sLoRaОпцииРазширени
@@ -481,6 +486,17 @@
Честотен слотИгнориране на MQTTКонфигуриране на MQTT
+ Неактивен
+ Прекъсната връзка
+ Свързване…
+ Свързано
+ Повторно свързване…
+ Повторно свързване (опит %1$d) — %2$s
+ Тестване на връзката
+ Достъпен. Брокерът е приел идентификационните данни.
+ Достъпен (%1$s)
+ Хостът не е намерен
+ Връзката е неуспешнаMQTT е активиранАдресПотребителско име
@@ -958,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 @@
SempreTraçar rutaRegió
+ DesconnectatTemps esgotatDistànciaMeshtastic
@@ -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 51e156e5d..d3e0566ac 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -216,6 +216,7 @@
TmavýPodle systémuVyberte vzhled
+ VysokáPoskytnout polohu sítiÚsporné kódování pro cyriliku
@@ -263,6 +264,7 @@
DoručenoVaše zařízení se může odpojit a restartovat při aplikaci nastavení.Chyba
+ Neznámá chybaIgnorovatOdstranit z ignorovanýchPřidat '%1$s' do seznamu ignorovaných?
@@ -311,6 +313,7 @@
BaterieChUtilAirUtil
+ %1$s%1$s: %2$sTeplotaVlhkost
@@ -328,12 +331,9 @@
Informace o uživateliOznámení o nových uzlechSNR
- 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 uzluPozicePoslední aktualizace poziceMetriky prostředí
@@ -522,6 +522,8 @@
Ignorovat MQTTOK do MQTTNastavení MQTT
+ Odpojeno
+ PřipojenoMQTT povolenoAdresaUživatelské jméno
@@ -965,4 +967,6 @@
PoznámkaPřipojitHotovo
+ 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 a358cb984..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,15 @@
Auf Standardeinstellungen zurücksetzenAnwendenDesign
+ KontrastHellDunkelSystemDesign auswählen
+ Kontrast
+ Standard
+ Medium Fast
+ HochStandort zum Mesh angebenKompakte Kodierung für Kyrillisch
@@ -302,6 +307,7 @@
Zustellung bestätigtIhr Gerät kann die Verbindung trennen und neu starten, während die Einstellungen angewendet werden.Fehler
+ Unbekannter FehlerIgnorierenAus Ignorierliste entfernen'%1$s' zur Ignorieren-Liste hinzufügen?
@@ -353,9 +359,9 @@
AkkuKanalauslastungSendezeit
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s V
+ %1$s%1$s: %2$sTemperaturFeuchtigkeit
@@ -377,12 +383,9 @@
BenutzerinfoBenachrichtigung neue KnotenSNR
- 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 KnotenStandortLetzte StandortaktualisierungUmweltdaten
@@ -429,7 +432,6 @@
1 MonatMaximalMinimum
- DurchschnittDiagramm einblendenDiagramm ausblendenAlter unbekannt
@@ -581,6 +583,9 @@
Ausgabedauer (GPIO)Nervige Verzögerung (Sekunden)Klingelton
+ Importierter Klingelton
+ Datei ist leer
+ Fehler beim Importieren: %1$sWiedergabeI2S als Buzzer verwendenLoRa
@@ -604,6 +609,23 @@
MQTT ignorierenOK für MQTTMQTT 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 fehlgeschlagenMQTT aktiviertAdresseBenutzername
@@ -719,6 +741,7 @@
Regen (24 Std.)GewichtStrahlung
+ 1-Wire TemperatureLuftqualität im Innenbereich (IAQ)URL
@@ -1198,4 +1221,21 @@
Netzwerk eingeben oder auswählenWLAN 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 coincideNotificaciones de nuevo nodoSNR
- 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 NodosPosiciónÚltima actualizaciónMétricas de Entorno
@@ -492,6 +489,8 @@ Rango de Valores 0 - 500.Ignorar Paquetes MQTTPermitir MQTTConfiguración MQTT
+ Desconectado
+ ConectadoActivar el MQTTDirección del Servidor MQTTUsuario
@@ -836,4 +835,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
VerdeConectarHecho
+ 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 969d46acb..c2e327629 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -253,10 +253,15 @@
Taasta vaikesättedRakendaTeema
+ KontrastsusHeleTumeSüsteemi vaikesäteVali teema
+ Kontrastsuse tase
+ Standard
+ Keskmine
+ KõrgeJaga telefoni asukohta mesh-võrkuKompaktne kodeering kirillitsa jaoks
@@ -302,6 +307,7 @@
Kohale toimetatudSeadete rakendamise ajal võib seadme ühendus katkeda ja taaskäivituda.Viga
+ Tundmatu vigaEiraEemalda ignoreeritute hulgastLisa '%1$s' eiramis loendisse?
@@ -353,9 +359,9 @@
AkuKanali kasutusSaate kasutus
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s V
+ %1$s%1$s: %2$sTemperatuurNiiskus
@@ -377,12 +383,9 @@
Kasutaja teaveUue sõlme teadeSNR
- 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 kaartAsukohtViimase asukoha värskendusKeskkonnamõõdikud
@@ -429,7 +432,6 @@
1kMaksimaalseltMin
- KeskmLaienda diagrammiAhenda diagrammiTundmatu vanus
@@ -581,6 +583,9 @@
Väljundi kestvus (millisekundit)Häire ajalõpp (sekundit)Helin
+ Imporditud helin
+ Fail on tühi
+ Viga importimisel: %1$sMängi etteKasuta I2S summerinaLoRa
@@ -604,6 +609,23 @@
Keela MQTTOk MQTTiMQTT 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õnnestusMQTT lubatudAadressKasutajatunnus
@@ -719,6 +741,7 @@
Vihm (24h)KaalRadiatsioon
+ 1-juhtmeline temperatuurSiseõhu kvaliteet (IAQ)URL
@@ -1198,4 +1221,21 @@
Sisestage või valige võrkWiFi 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 98a2fc84c..f9da71dea 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -253,10 +253,15 @@
Palauta oletusasetuksetHyväksyTeema
+ KontrastiVaaleaTummaJärjestelmän oletusValitse teema
+ Kontrastin taso
+ Normaali
+ Keskitaso
+ KorkeaJaa puhelimen sijaintitietoa mesh-verkkoonKyrillisten merkkien tiivis koodaus
@@ -302,6 +307,7 @@
Toimitus vahvistettuLaitteesi saattaa katkaista yhteyden ja käynnistyä uudelleen, kun asetuksia otetaan käyttöön.Virhe
+ Tuntematon virheJätä huomiottaPoista huomioimattomistaLisää '%1$s' jätä huomiotta listalle? Laite käynnistyy uudelleen muutoksen tekemisen jälkeen.
@@ -353,9 +359,9 @@
AkkuKanavan käyttöasteLähetysajan käyttöaste
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s V
+ %1$s%1$s: %2$sLämpötilaKosteus
@@ -377,12 +383,9 @@
KäyttäjätiedotUuden laitteen ilmoituksetSNR
- 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
- LaitekarttaSijaintiViimeisin sijainnin päivitysYmpäristöarvot
@@ -429,7 +432,6 @@
1 kkKaikkiMinimi
- KeskiarvoLaajenna kaavioPienennä kaavioTuntematon ikä
@@ -581,6 +583,9 @@
Ulostulon kesto (millisekuntia)Hälytysaikakatkaisu (sekuntia)Soittoääni
+ Tuotu soittoääni
+ Tiedosto on tyhjä
+ Virhe tuotaessa: %1$sAloitaKäytä I2S protokollaa äänimerkilleLoRa
@@ -604,6 +609,23 @@
Ohita MQTTMQTT 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äonnistuiMQTT käytössäOsoiteKäyttäjänimi
@@ -719,6 +741,7 @@
Sademäärä (24 h)PainoSäteily
+ Lämpötila (1-Wire)Sisäilmanlaatu (IAQ)URL-osoite
@@ -1199,4 +1222,21 @@
Syötä tai valitse verkkoWiFi 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 fe1a9aaef..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$sFiltreEffacer le filtre de nœudFiltrer par
@@ -40,9 +41,11 @@
Internepar FavorisAfficher uniquement les nœuds ignorés
+ Exclure MQTTNon reconnuEn attente d'accusé de réceptionEn file d'attente pour l'envoi
+ Délivré au nœudInconnuRoutage 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 coursNon 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émoConnecté à la radio, mais en mode veilleMise à jour de l’application requiseVous 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 serviceRemerciements
+ 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èquesCette URL de canal est invalide et ne peut pas être utiliséePanneau de débogageContenu décodé :
@@ -207,7 +218,21 @@
Correspondre à tout | N'importe quelCela 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'actionsCanal
+ %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 stylesStatut d'envoi du messageNouveaux messages au-dessousNotifications de message
@@ -228,10 +253,15 @@
Rétablir les valeurs par défautAppliquerThème
+ ContrasteClairSombreValeur par défaut du systèmeChoisir un thème
+ Niveau de contraste
+ Standard
+ Milieu
+ HautFournir l'emplacement au maillageEncodage compact pour Cyrillique
@@ -275,7 +305,9 @@
Message directReconfiguration de NodeDBRé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 produiteIgnorerSupprimer des ignorésAjouter '%1$s' à la liste des ignorés ? Votre radio va redémarrer après avoir effectué ce changement.
@@ -316,6 +348,8 @@
Actuellement :Toujours muetNon muet
+ Muet pour %1$d jours, %2$s heures
+ Muet pour %1$s heuresDésactiver les notifications pour '%1$s' ?Réactiver les notifications pour '%1$s' ?Remplacer
@@ -325,6 +359,10 @@
BatterieUtilCanalUtilAir
+ %1$s / %2$s%%
+ %1$s: %2$s V
+ %1$s
+ %1$s: %2$sTempHumTemp sol
@@ -345,12 +383,9 @@
Infos utilisateurNotifikasyon nouvo nœudSNR
- 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 positionsPositionDernière mise à jour de positionMétriques d'environnement
@@ -379,13 +414,26 @@
Durée : %1$s sRoute aller :\n\nRoute retour :\n\n
+ Saut vers l'avant
+ Saut vers l'arrière
+ Aller/RetourPas 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 octets1H24H1S2S1MMax
+ Min
+ Agrandir le graphique
+ Réduire le graphiqueAge inconnuCopierCaractère d'appel !
@@ -399,11 +447,17 @@
Canal 1Canal 2Canal 3
+ Canal 4
+ Canal 5
+ Canal 6
+ Canal 7
+ Canal 8ActifTensionÊ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 faibleBatterie faible : %1$sNotifications de batterie faible (nœuds favoris)
@@ -529,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$sLancerUtiliser l'I2S comme buzzerLoRa
@@ -552,6 +609,13 @@
Ignorer MQTTTransmission des paquets vers MQTTConfiguration MQTT
+ Inactif
+ Déconnecté
+ Connexion…
+ Connecté
+ Reconnexion…
+ Test de la connexion
+ Échec de la connexionMQTT activéAdresseNom d'utilisateur
@@ -623,6 +687,8 @@
Série activéeÉcho activéVitesse de transmission série
+ RX
+ TxDélai d'expirationMode sérieOutrepasser le port série de la console
@@ -657,8 +723,15 @@
DistanceLuxVent
+ Vitesse du vent
+ Rafales de vent
+ Vent à la traîne
+ Direction du vent
+ Pluie (1h)
+ Pluie (24h)PoidsRadiation
+ Températeur 1-WireQualité de l'air intérieur (IAQ)URL
@@ -675,6 +748,7 @@
HorodatageEn-têteVitesse
+ %1$d Km/hSatsAltFréq
@@ -740,6 +814,11 @@
Afficher les points de repèreAfficher les cercles de précisionNotification 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éeClé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.
@@ -792,7 +871,14 @@
Composer un messageMétriques de PAXPAX
+ PAX : %1$d
+ B:%1$d
+ W :%1$d
+ PAX : %1$s
+ BLE: %1$s
+ Wi-Fi : %1$sAucune métrique PAX disponible.
+ Approvisionnement Wi-Fi pour mPWRD-OSAppareils BluetoothPériphérique connectéLimite de débit dépassée. Veuillez réessayer plus tard.
@@ -847,6 +933,8 @@
TerrainHybrideGé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 calqueAfficher le calqueSupprimer le calque
@@ -854,6 +942,10 @@
Nœuds à cet emplacementType 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éeLe nom ne peut pas être vide.Le nom du fournisseur existe déjà.URL ne peut être vide.
@@ -947,6 +1039,7 @@
Notes de VersionUne erreur inconnue s'est produiteLes 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 USBIntégrité (hash) du firmware rejetée. Veuillez réessayer ou mettre à jours l'appareil via USB.
@@ -1023,8 +1116,10 @@
ConfigurationGé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 totalTemps 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
@@ -1038,10 +1133,94 @@
ActualiserMis à 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
+ MagentaRouge
+ Marron
+ Pourpre
+ Bleu foncéBleu
+ Cyan
+ TurquoiseVert
+ 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 traficModule 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é.ConnecterTerminé
+ 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í EochairMí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 NodeRialachasRialú iargúltaGo dona
@@ -213,6 +210,7 @@
Céimeanna i dtreo %1$d Céimeanna ar ais %2$dRéigiún
+ Na ceangailteAm tráthSá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 @@
SempreTraza-rutaRexión
+ DesconectadoDistancia
@@ -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 @@
DetaljiCrvenoRegija
+ OdspojenoUdaljenostMeshtastic
@@ -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 PiblikPa matche kle piblikNotifikasyon 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œudAdministrasyonAdministrasyon RemoteMove
@@ -201,6 +198,7 @@
DirekHops vèsus %1$d Hops tounen %2$dRejyon
+ DekonekteTan paseDistans
@@ -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ésekSNR
- 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épPozícióUtolsó pozíciófrissítésKörnyezeti metrikák
@@ -515,6 +512,8 @@
MQTT figyelmen kívül hagyásaMQTT-re továbbíthatóMQTT beállítások
+ Szétkapcsolva
+ CsatlakoztatvaMQTT engedélyezveCímFelhasználónév
@@ -850,4 +849,6 @@
KékZöldCsatlakozá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önnunSvæð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 8e9066c22..baa0e0947 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -239,6 +239,8 @@
ScuroPredefinito di sistemaScegli tema
+ Medium
+ AltoFornire la posizione alla meshCodifica compatta per cirillico
@@ -284,6 +286,7 @@
Consegna confermataIl dispositivo potrebbe disconnettersi e riavviarsi durante l'applicazione delle impostazioni.Errore
+ Errore sconosciutoIgnoraRimuovi da ignoratiAggiungere '%1$s' alla lista degli ignorati?
@@ -352,12 +355,9 @@
Informazioni UtenteNotifiche di nuovi nodiSNR
- 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 NodiPosizioneAggiornamento ultima posizioneMetriche Ambientali
@@ -557,6 +557,8 @@
Ignora MQTTOK per MQTTConfigurazione MQTT
+ Disconnesso
+ ConnessoMQTT abilitatoIndirizzoUsername
@@ -957,4 +959,6 @@
NoteConnettiFatto
+ 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 5b53fd292..64aa0fe05 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -263,6 +263,7 @@
WiFi認証のQRコードの形式が無効です前に戻るバッテリー
+ %1$sログホップ数情報
@@ -274,11 +275,8 @@
公開キーが一致しません新しいノードの通知SN比
- 信号対ノイズ比(SN比)は、通信において、目的の信号のレベルを背景ノイズのレベルに対して定量化するために使用される尺度です。Meshtasticや他の無線システムでは、SN比が高いほど信号が鮮明であることを示し、データ伝送の信頼性と品質を向上させることができます。RSSI
- 受信信号強度インジケーター(RSSI)は、アンテナで受信している電力レベルを測定するための指標です。一般的にRSSI値が高いほど、より強力で安定した接続を示します。(屋内空気品質) 相対スケールIAQ値は、ボッシュBME680によって測定されます。 値の範囲は 0-500。
- ノードマップ位置管理リモート管理
@@ -423,6 +421,8 @@
PAファン無効MQTT を無視MQTT設定
+ 切断
+ 接続済MQTTを有効化アドレスユーザー名
@@ -651,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šimasSNRRSSI
- Įtaisų žemėlapisAdministravimasNuotolinis administravimasSilpnas
@@ -217,6 +216,7 @@
Skambučio simbolis!RaudonaRegionas
+ AtsijungtaViešasis raktasPrivatus raktasBaigė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 overeenNieuwe node meldingenSNR
- 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 KaartPositieBeheerExtern beheer
@@ -312,6 +309,8 @@
Inkomende negerenNegeer MQTTMQTT Configuratie
+ Niet verbonden
+ VerbondenMQTT ingeschakeldAdresGebruikersnaam
@@ -416,4 +415,5 @@
BlauwGroenVerbinding 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 noderSNR
- 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.
- NodekartAdministrasjonFjernadministrasjonDårlig
@@ -222,6 +219,7 @@
KopierVarsel, bjellekarakter!Region
+ FrakobletOffentlig nøkkelPrivat nøkkelTidsavbrudd
@@ -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 448e7eaac..7c9b3433b 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -223,6 +223,7 @@
CiemnyDomyślne ustawienie systemoweWybierz motyw
+ StandardowyPodaj lokalizację telefonu do sieciUsunąć wiadomość?
@@ -268,6 +269,7 @@
Zresetuj NodeDBDostarczonoBłąd
+ Nieznany błądZignorujUsuń z listy ignorowanychDodać '%1$s' do listy ignorowanych?
@@ -331,12 +333,9 @@
Informacje o użytkownikuPowiadomienia o nowych węzłachSNR:
- 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 mapiePozycjonowanieOstatnia aktualizacja lokalizacjiMetryki środowiskowe
@@ -497,6 +496,8 @@
Zignoruj MQTTOk dla MQTTKonfiguracja MQTT
+ Rozłączono
+ PołączonyWłącz MQTTAdresNazwa użytkownika
@@ -747,4 +748,6 @@
Moduł WłączonyPołączWykonano
+ 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 confereNovas 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çãoAtualização da última posiçãoAdministração
@@ -382,6 +379,8 @@
Ventilador do PA desativadoIgnorar MQTTConfigurações MQTT
+ Desconectado
+ ConectadoMQTT habilitadoEndereçoNome de usuário
@@ -665,4 +664,6 @@
AzulVerdeConcluí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úblicaNotificações de novos nodesSNR
- 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 nodesPosiçãoAdministraçãoAdministração Remota
@@ -366,6 +363,8 @@
Ignorar entradaIgnorar MQTTConfiguração MQTT
+ Desconectado
+ LigadoMQTT ativoEndereçoUtilizador
@@ -515,4 +514,6 @@
AzulVerdeLigar
+ 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 8206e5aaf..f9787ba93 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -35,11 +35,15 @@
Ultima recepțievia MQTTvia MQTT
+ Interndupă favoriteArată doar nodurile ignorate
+ Exclude MQTTNerecunoscutÎn așteptarea confirmăriiÎn coadă pentru trimitere
+ Livrat la Mesh
+ NecunoscutRutare prin lanțul SF++…Confirmat pe lanțul SF++Confirmat
@@ -89,6 +93,9 @@
Busola de pe ecran, în afara cercului, va indica întotdeauna nordul.Rotire ecran vertical.Unitățile afișate pe ecranul dispozitivului.
+ Suprascrie ecranul OLED automat.
+ Suprascrie aspectul implicit al ecranului.
+ Îngroşează textul din antet de pe ecran.Necesită ca dispozitivul dvs. să aibă un accelerometru.Regiunea în care veți folosi radioul.Presetările modemului disponibile, implicit este Long Fast (Rază lungă - rapid).
@@ -110,9 +117,10 @@
Intervalul maxim care poate trece fără ca un nod să transmită o poziție.Cea mai rapidă actualizare a poziției care va fi trimisă dacă distanța minimă a fost respectată.Modificarea minimă a distanței în metri care trebuie luată în considerare pentru o transmisie inteligentă a poziției.
- Cât de des ar trebui să se încerce obținerea locației GPS (<10 secunde menține GPS-ul activ).
+ Cât de des ar trebui să încercăm să obținem o poziție GPS (<10sec păstrează GPS activat).Câmpuri opționale care trebuie incluse la asamblarea mesajelor de poziție. Cu cât sunt incluse mai multe câmpuri, cu atât mesajul va fi mai mare, ceea ce va duce la un timp de transmisie mai lung și la un risc mai mare de pierdere a pachetelor.Va păstra totul în repaus cât mai mult posibil, pentru rolul de tracker și senzor, aceasta va include și radioul LoRa. Nu utilizați această setare dacă doriți să utilizați dispozitivul cu aplicațiile telefonului sau dacă utilizați un dispozitiv fără buton de utilizator.
+ Generată din cheia publică și trimisă către alte noduri din rețea pentru a le permite să calculeze o cheie secretă comună.Utilizat pentru a crea o cheie partajată cu un dispozitiv la distanță.Cheia publică autorizată să trimită mesaje de administrare către acest nod.Dispozitivul este gestionat de un administrator de rețea, utilizatorul neputând accesa niciuna dintre setările dispozitivului.
@@ -151,7 +159,7 @@
DistribuieNod nou găsit: %1$sDeconectat
- Dispozitiv în sleep mode
+ Adormirea dispozitivuluiAdresa IP:Port:Conectat
@@ -161,14 +169,22 @@
ConectareNeconectatNici un dispozitiv selectat
+ Dispozitiv necunoscut
+ Nici un dispozitiv de rețea găsit
+ Niciun dispozitiv USB găsit
+ USB
+ Mod demonstrativConnectat la dispozitivi, dar e în modul de sleepAplicație prea vecheTrebuie să updatezi această aplicație de pe Google Play (sau Github). Aplicația este prea veche pentru a comunica cu dispozitivul.Niciunul (dezactivat)Notificările serviciuluiMulțumiri
+ Biblioteci open source
+ Meshtastic este construit cu următoarele biblioteci open source. Atingeți orice bibliotecă pentru a vedea licența.
+ Librării %1$dAcest URL de canal este invalid și nu poate fi folosit
- Panou debug
+ Panou de depanareDate decodate:Export jurnale%1$d (de) jurnale exportate
@@ -194,14 +210,28 @@
Ștergeți toate filtreleAdăugare filtru personalizatPresetări filtre
- Salvează jurnalele din mesh
- Dezactivați pentru a omite scrierea jurnalelor din mesh pe disc
+ Salvează jurnalele din retea
+ Dezactivați pentru a omite scrierea jurnalelor din retea pe discȘtergeți jurnalelePotrivire oricare | toatePotrivire toate | oricareAceasta va șterge toate pachetele de jurnal și intrările din baza de date de pe dispozitivul dvs. - Este o resetare completă și este permanentă.Șterge
+ Căutare emoji-uri...
+ Mai multe reacţiiCanal
+ %1$s:%2$s
+ Mesaj de la %1$s %2$s
+ Antet
+ Obiect %1$d
+ Subsol
+ Casetă
+ Bulină
+ Text
+ Indicator
+ Degrade
+ Acesta este un element compozabil personalizat
+ Cu mai multe linii şi stiluriStatus livrare mesajMesaje noi mai josNotificări mesaje directe
@@ -227,6 +257,7 @@
Setarea telefonuluiAlege temaFurnizați locația telefonului la mesh
+ Codare compactă pentru chirilicăȘtergeți mesajul?Ștergeți %1$s mesaje?
@@ -252,7 +283,7 @@
⚠️ Aceasta va OPRI nodul. Pentru a-l repune în funcțiune, va fi necesară o intervenție fizică.Nod: %1$sRestartează
- Traceroute
+ Trasare traseuArată IntroducereMesajOpțiuni chat rapid
@@ -269,7 +300,9 @@
Mesaj directResetare NodeDBLivrare confirmată
+ Dispozitivul dumneavoastră se poate deconecta şi reporni în timp ce setările sunt aplicate.Eroare
+ Eroare necunoscutaIgnorăEliminați din lista ignorate Adaugă '%1$s' in lista de ignor? Radioul tău va reporni după ce această modificare.
@@ -310,6 +343,8 @@
În prezent:Mereu silențiosNu este silențios
+ Silențios pentru %1$d zile, %2$s ore
+ Silențios pentru %1$s oreDezactivați notificările pentru „%1$s”?Activați notificările pentru '%1$s'?Înlocuire
@@ -319,6 +354,8 @@
BaterieChUtilAirUtil
+ %1$s
+ %1$s:%2$sTempHumTemp sol
@@ -339,12 +376,9 @@
Info utilizatorNotificări noduri noiSNR
- 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 nodurilorPozițieUltima actualizare a pozițieiIndicatori de mediu
@@ -374,11 +408,21 @@
Durată: %1$s sRuta trasată spre destinație:\n\nRuta urmărită înapoi la noi:\n\n
+ Redirecționare Hops
+ Hops de returnare
+ Dus-întors
+ Niciun raspuns
+ Încărcare 1m
+ Încărcare 5m
+ Încărcare 15m
+ Încărcătura medie a sistemului de un minutMedia de încărcare sistem de cinci minute24H1W2WMaxim
+ Extindeți graficul
+ Restrânge graficulVârstă necunoscutăCopiereCaracter clopoțel de alertă!
@@ -392,11 +436,17 @@
Canalul 1Canalul 2Canalul 3
+ Canalul 4
+ Canalul 5
+ Canalul 6
+ Canalul 7
+ Canalul 8ActualTensiuneSunteți sigur?Documentația privind rolul dispozitivului și articolul de blog despre Alegerea rolului potrivit pentru dispozitiv.]]>Știu ce fac.
+ Nodul %1$s are bateria descărcată (%2$d%)Notificări pentru baterii descărcateBaterie descărcată: %1$sNotificări pentru baterii descărcate (noduri favorite)
@@ -527,12 +577,26 @@
LoRaOpțiuniAvansate
+ Utilizare presetarePresetăriLățime bandă
+ Factor de răspândire
+ Rata de codificareRegiune
+ Numărul de Hops
+ Transmisie activată
+ Putere transmisie
+ Slot pentru frecvenţă
+ Suprascrie ciclul de obligații
+ Ignoră primirea
+ Amplificare RX amplificată
+ Suprascriere frecvență
+ Ventilator PA dezactivatIgnoră MQTTAcceptă MQTTConfigurare MQTT
+ Deconectat
+ ConectatMQTT activatAdresăNume de utilizator
@@ -540,63 +604,568 @@
Criptare activatăIeșire JSON activatăTLS activat
+ Temă rădăcină
+ Proxy-ul pentru client activat
+ Raportarea hărții
+ Intervalul de raportare hartă (secunde)
+ Configurare informații vecin
+ Info vecin activat
+ Interval de actualizare (secunde)
+ Transmite peste LoRA
+ Optiuni Wi-FiActivat
+ WiFi activat
+ Numele rețelei
+ PSK
+ Opţiuni Ethernet
+ Ethernet activat
+ Server NTP
+ server rsyslog
+ Mod IPv4
+ IP
+ Poartă de acces
+ Subred
+ DNSConfigurație PaxcounterPaxcounter activat
+ Mesaj de stare:
+ Configurare mesaj prestabilit
+ Șirul de stare realPragul WiFi RSSI (implicit la -80)
+ Latitudine
+ Longitudine
+ Setează din locația curentă a telefonului
+ Mod GPS (hardware fizic)
+ Steaguri poziție
+ Configurare Putere
+ Activează modul de economisire a energiei
+ Închidere la pierderea de energie
+ Suprascriere multiplicator ADC
+ Raportul suprascrierii multiplicatorului ADC
+ Așteptați pentru durata Bluetooth
+ Durată maximă de somn
+ Durata minimă a trezirii
+ Adresa baterie INA_2XX I2C
+ Configurare test interval
+ Testul de gamă activat
+ Interval mesaj expeditor (secunde)
+ Salvați .CSV doar în memorie (ESP32)
+ Configurare hardware la distanță
+ Hardware extern activat
+ Permite acces Pin nedefinit
+ Pin-uri disponibile
+ Mesaj direct
+ Chei Admin
+ Chei publice
+ Cheia privată
+ Cheie Administrator
+ Mod Gestionat
+ Consolă serială
+ Debug log API activat
+ Canal implicit de administrator
+ Configurație serial
+ Serial activat
+ Echo activat
+ Rata baud-ului serial
+ RX
+ TXExpirat
+ Mod serial
+ Suprascrie portul serial al consolei
- Valorile mediului utilizează Fahrenheit
+ Puls
+ Numarul de inregistrari
+ istoric număr maxim de retur
+ Fereastra de returnare a istoricului
+ Server
+ Configurare telemetrie
+ Intervalul de actualizare a parametrilor dispozitivului
+ Interval actualizare valori mediu
+ Modul de măsurare mediu activat
+ Valorile de mediu pe ecran sunt activate
+ Valorile de mediu utilizează Fahrenheit
+ Interval actualizare măsurători de calitate a aerului
+ Pictograma calităţii aerului
+ Modul de măsurare putere activat
+ Interval actualizare măsurători de putere
+ Valori pe ecran activate
+ Configurare utilizator
+ ID-ul NoduluiNume lungNume scurtModel hardware
+ Radioamator autorizat
+ Activarea acestei opțiuni dezactivează criptarea și nu este compatibilă cu rețeaua implicită de Meshtastic.Punct de rouăPresiune
+ Rezistența la gazDistanță
+ LuxVânt
+ Viteza vântului
+ Viteza rafale
+ Vânt critic
+ Directie vânt
+ Ploaie (1h)
+ Ploaie (24h)GreutateRadiație
+ Calitatea aerului interior (IAQ)URL
+
+ Importă configurația
+ Exportă configurația
+ Dispozitive
+ Suportate
+ Număr modul
+ ID utilizator
+ Timp de functionare
+ Încărcare %1$d
+ Disc liber %1$d
+ Data si ora
+ Direcție
+ Viteza
+ %1$d Km/h
+ Sateliți
+ Alt
+ Frecvență
+ Slot
+ Primară
+ Poziție periodică și transmisiune telemetrică
+ Secundar
+ Nicio transmisiune periodică telemetrie
+ Solicitarea de poziție manuală este necesară
+ Apăsați și trageți pentru a reordona
+ Activare sunet
+ Dinamic
+ Împărtășește contacte
+ Notițe
+ Adaugă o notiță privată
+ Importați contactul partajat?
+ NetransmisibilNemonitorizată sau infrastructură
-
+ Cheie publică schimbată
+ Importa
+ Solicitare
+ Se solicită %1$s de la %2$s
+ Informații utilizator
+ Solicită telemetrieValori dispozitivIndicatori de mediu
+ Calitatea aerului, valoareValori putere
+ Valori Gazdă
+ Valori Pax
+ Metadate
+ Acţiuni
+ Firmware
+ Utilizaţi formatul ceasului 12h
+ Când este activat, dispozitivul va afișa pe ecran ora în format 12 ore.
+ Valori Gazdă
+ Gazdă
+ Memorie Liberă
+ Încarcă
+ Șir Utilizator
+ Navigați în
+ Conexiune
+ Harta retea
+ Conversații
+ Noduri
+ Setări
+ Selectat
+ Setează-ți regiunea
+ Răspunde
+ Nodul tău va trimite periodic un pachet de rapoarte de hărți necriptate serverului MQTT configurat, acesta include un nume id, lung și scurt, aproximează locația, modelul hardware, rolul, versiunea firmware, regiunea LoRa, presetarea modemului și numele canalului primar.
+ Consimțământ pentru a Partaja date Node necriptate prin MQTT
+ Prin activarea acestei caracteristici, acceptați și consimți în mod expres transmiterea locației geografice în timp real a dispozitivului dvs. peste protocolul MQTT fără criptare. Aceste date de localizare pot fi utilizate în scopuri cum ar fi raportarea hărților live, urmărirea dispozitivelor și funcțiile telemetrice aferente.
+ Am citit şi înţeleg cele de mai sus. Sunt de acord voluntar cu transmiterea necriptată a datelor nodului prin MQTT
+ Sunt de acord
+ Actualizare firmware recomandată.
+ Pentru a beneficia de cele mai recente remedieri și caracteristici, vă rugăm să vă actualizați firmware-ul node.\n\nUltima versiune de firmware stabilă: %1$s
+ Expiră la
+ Timp
+ Dată
+ Filtru Hartă\n
+ Doar FavoriteArată repere
- Ești sigur că vrei să-ți regenerezi cheia privata?\n\nNodurile care ar fi putut schimba anterior chei cu acest modul vor trebui să elimine acel nod şi să schimbe din nou cheile pentru a relua comunicarea securizată.
+ Arată cercuri de precizie
+ Notificare client
+ Verificare cheie
+ Solicitare de verificare cheie
+ Verificare cheie finalizată
+ Duplicat Cheie Publică detectată
+ Cheie Criptare slabă detectată
+ Chei promise detectate, selectaţi OK pentru regenerare.
+ Regenerează Cheia privată
+ Sunteţi sigur că doriţi să vă regeneraţi cheia privată?\n\nNodurile care ar fi putut schimba anterior chei cu acest modul vor trebui să elimine acel nod și să schimbe din nou tastele pentru a relua comunicarea securizată.
+ Modulele deblocate
+ Modulele sunt deja deblocate
+ De la distanta(%1$d online / %2$d afișate / %3$d în total)
+ Reacţionează
+ Deconectați
+ Derulare până josMeshtastic
+ Stare de securitate
+ Securizare
+ Insigna de avertizare
+ Canal necunoscut.
+ Avertizare
+ Meniu de Overflow
+ LUX UV
+ Necunoscut
+ Acest radio este gestionat și poate fi schimbat doar de un administrator de la distanță.Avansate
+ Curăță baza de date a nodurilor
+ Curăță nodurile văzute ultima dată mai vechi de %1$d zile
+ Curăță doar noduri necunoscute
+ Curăţă acum
+ Acest lucru va elimina %1$d noduri din baza ta de date. Această acțiune nu poate fi anulată.
+ O blocare verde înseamnă că canalul este criptat în siguranță cu o cheie de 128 sau 256 bit AES.
+ Canalul nesigur, nu este exact
+ Blocare deschisă galbenă înseamnă că canalul nu este criptat în siguranţă, nu este utilizat pentru date precise privind localizarea și nu utilizează nicio cheie sau o cheie cunoscută de 1 octet.
+ Canal nesigur, precizie locație
+ Blocarea roşie înseamnă că canalul nu este criptat în siguranţă, se utilizează pentru date precise privind localizarea și nu se utilizează nici o cheie sau o cheie citată.
+ Atenție: Locație nesigură, precisă & MQTT Uplink
+ Un lacăt roșu deschis cu un avertisment înseamnă că canalul nu este criptat în siguranță este utilizat pentru date precise privind localizarea care sunt conectate la internet prin intermediul MQTT, şi nu foloseşte nici o cheie sau o cheie cunoscută.
+ Securitate canal
+ Mijloace de securitate canale
+ Afișați toate mijloacele
+ Arată statusul actual
+ Renunțați
+ Răspunde la %1$s
+ Anulați răspunsul
+ Ștergeți mesajul?
+ Șterge selecțiaMesaj
+ Scrie un mesaj
+ Măsurători PAX
+ PAX
+ PAX: %1$d
+ B:%1$d
+ W:%1$d
+ PAX: %1$s
+ BLE: %1$s
+ WiFi: %1$s
+ Nu sunt disponibile măsurători PAX.
+ Wi-Fi Provisioning for mPWRD-OS
+ Dispozitive Bluetooth
+ Dispozitive conectateRata limită depășită. Te rugăm să încerci din nou mai târziu.
- Administreaza Layers Hartă
+ Descărcare
+ Instalate in acest moment
+ Ultimul stabil
+ Ultimul alfa
+ Sprijinită de comunitatea Meshtastic
+ Ediţie firmware
+ Dispozitive recente de rețea
+ Dispozitive ale rețelei descoperite
+ Dispozitive bluetooth disponibile
+ Să începem
+ Bine ai venit la
+ Rămâneţi conectat oriunde
+ Comunicați în afara grilei cu prietenii și comunitatea dvs. fără serviciu celular.
+ Creează-ţi propriile reţele
+ Înființarea cu ușurință a rețelelor private de plasare pentru comunicații sigure și fiabile în zonele îndepărtate;
+ Urmăriți și partajați locațiile
+ Partajați-vă locația în timp real și păstrați grupul coordonat cu funcții GPS integrate.
+ Notificări aplicații
+ Mesaje primite
+ Notificări pentru canal și mesaje directe.
+ Noduri Noi
+ Notificări pentru nodurile recent descoperite.
+ Baterie descarcata
+ Notificări pentru alerte de baterie scăzută pentru dispozitivul conectat.
+ Configurați permisiunile pentru notificări
+ Locaţia telefonului
+ Meshtastic folosește locația telefonului tău pentru a activa o serie de caracteristici. Poți actualiza permisiunile de locație în orice moment din setări.
+ Partajați locația
+ Folosește GPS-ul telefonului pentru a trimite locații către nodul tău în loc să folosești un GPS hardware de pe nodul tău.
+ Măsurătorile distanței
+ Afișează distanța dintre telefonul dvs. și alte noduri Meshtastice cu poziții.
+ Filtre distanță
+ Filtrează lista de noduri și harta plasei în funcție de proximitatea telefonului tău.
+ Locație hartă plasa
+ Activează punctul albastru pentru telefon în harta plasei.
+ Configurare permisiuni locație
+ Treci peste
+ setari
+ Alerte critice
+ Pentru a te asigura că primești alerte critice, cum ar fi mesajele
+ SOS, chiar și atunci când dispozitivul este în modul \"Nu deranja\" trebuie să acorzi permisiunea specială
+ . Vă rugăm să activați acest lucru în setările notificărilor.
+
+ Configurează alertele critice
+ Meshtastic folosește notificări pentru a te ține la curent cu mesaje noi și alte evenimente importante. Puteți actualiza permisiunile de notificare în orice moment din setări.
+ Următor
+ %1$d noduri aflate în așteptare pentru ștergere:
+ Atenție: Acest lucru elimină nodurile din bazele de date in-app și on-device.\nSelecțiile sunt aditive.
+ Normal
+ Prin satelit
+ Teren
+ Hibridă
+ Gestionează Layers Hartă
+ Nu s-au încărcat straturi de hărți.
+ Ascunde Layer
+ Arată Layer
+ Elimină strat
+ Adăugați un strat
+ Noduri în această locație
+ Tipul hărții selectate
+ Gestionează surse personalizate de stil
+ Adaugă sursă de rețea Tile
+ Nu s-au găsit surse de comutare personalizată.
+ Modifică sursa rețelei
+ Ştergeţi sursa de reţea
+ Numele nu poate fi gol
+ Nume furnizor exista.
+ Adresa URL nu poate fi goală.
+ URL-ul trebuie să conţină substituenţi.
+ Şablon URL
+ punct de traseu
+ Aplicaţie
+ Versiune
+ Funcții canal
+ Partajarea locației
+ Pozitie periodica
+ Mesajele de la mesageria va fi trimise pe internet public prin intermediul oricărui portal configurat de nod.Mesajele provenite de la o un gateway public de internet sunt redirecționate către rețeaua locală. Datorită politicii de zero salturi, traficul provenit de la serverul MQTT implicit nu se va propaga mai departe de acest dispozitiv.
+ Semne pictograme
+ Dezactivarea poziției pe canalul primar permite transmisii periodice de poziție pe primul canal secundar cu poziția activată, altfel solicitarea manuală a poziției este necesară.
+ Configurare dispozitiv
+ "[Remote] %1$s"
+ Trimite telemetrie dispozitivActivează/dezactivează modulul de telemetrie al dispozitivului pentru a trimite metrici către rețeaua mesh. Acestea sunt valori nominale. Rețelele mesh congestionate se vor scala automat la intervale mai lungi, în funcție de numărul de noduri online.
- O oră
+ Oricare
+ 1 Oră8 Ore24 Ore48 Ore
+ Filtrați după ultima oră: %1$s
+ %1$d dBm
+ Setări ale sistemului
+ Nici o statistică disponibilă
+ Analytics sunt colectate pentru a ne ajuta să îmbunătățim aplicația Android (mulțumesc), vom primi informații anonime despre comportamentul utilizatorului. Aceasta include rapoarte de accidente, ecrane folosite în aplicație, etc.
+ Platforme analitice:
+ Pentru mai multe informații, consultați politica noastră de confidențialitate.
+ Nesetat - 0
+ %1$s de obicei este livrat cu un bootloader care nu acceptă actualizări OTA. Este posibil să fie nevoie să instalați un bootloader OTA prin USB înainte de a instala OTA.
+ Pentru RAK WisBlock RAK4631, folosiți unealta DFU serială's (de exemplu, seria adafruit-nrfutil dfu cu bootloader furnizat. fișier ip). Copierea fișierului .uf2 nu va actualiza bootloader-ul singur.
+ Don't arată din nou pe acest dispozitiv
+ Păstrează favoritele?
+ Actualizare firmware
+ Căutare actualizări...
+ Dispozitiv: %1$s
+ Instalat în prezent: %1$s
+ Actualizare către: %1$sStabil
+ Notă: Aceasta va deconecta temporar dispozitivul dumneavoastră în timpul actualizării.
+ Se descarcă firmware... %1$d%
+ Eroare: %1$s
+ Reîncercați
+ Actualizare reușită!
+ Gata
+ Se pornește DFU...
+ Se activează modul DFU...
+ Se validează firmware-ul...
+ Model hardware necunoscut: %1$d
+ Niciun dispozitiv conectat
+ Nu am putut găsi firmware-ul pentru %1$s în versiune
+ Extragere firmware...Actualizare eșuată
+ lucrăm la acest lucru...
+ Ţineţi dispozitivul aproape de telefon.
+ Nu închideți aplicația.
+ Aproape gata...
+ Acest lucru ar putea dura un minut...
+ Selectare fișier local
+ Fișier local
+ Sursa: Fișier Local
+ Lansare la distanţă necunoscută
+ Avertisment actualizare
+ Sunteți pe cale să instalați firmware-ul nou pe dispozitiv. Acest proces poartă riscuri.\n\n• Asigurați-vă că aparatul este încărcat.\n• Țineți dispozitivul aproape de telefon.\n• Nu închideți aplicația în timpul actualizării.\n\nVerificați că ați selectat firmware-ul corect pentru hardware-ul dumneavoastră.
+ Chirpy spune, \"Ţineţi-vă scara la îndemână!\"
+ Chirpy
+ Repornirea pe DFU...
+ High-cinci! Așteptați, copiere firmware-ul...
+ Vă rugăm să salvaţi fişierul .uf2 pe dispozitivul dvs's DFU unitate.
+ Atașare dispozitiv, vă rog așteptați...
+ Transfer fişier USB
+ BLE OTA
+ WiFi OTA
+ Updateaza către %1$s
+ Selectați DFU USB disk
+ Dispozitivul dvs. a repornit în modul DFU şi ar trebui să apară ca un dispozitiv USB (de exemplu, RAK4631).\n\nCând se deschide selectorul de fişiere, vă rugăm să selectaţi rădăcina acelui disc pentru a salva fişierul de firmwar.
+ Verific actualizarea...
+ Verificarea a expirat. Dispozitivul nu a reconectat în timp.
+ Se așteaptă ca dispozitivul să se reconecte...
+ Target: %1$s
+ Note de lansare
+ Eroare necunoscuta
+ Informațiile utilizatorului nodului lipsesc.
+ Baterie prea mică (%1$d%). Vă rugăm să încărcați dispozitivul înainte de actualizare.
+ Nu s-a putut recupera fișierul de firmware.
+ Actualizare USB nereuşită
+ Hash firmware respins. Dispozitivul poate avea nevoie de provizioane hash sau actualizare bootloader
+ Actualizare OTA esuata: %1$s
+ Se așteaptă ca dispozitivul să se repornească în modul OTA...Conectarea la dispozitiv (încercarea %1$d/%2$d)...
+ Încărcare firmware...
+ Ştergere...
+ ÎnapoiNesetat
+ Mereu pornit%1$d oră%1$d ore%1$d de ore
+ Busolă
+ Deschide busola
+ Distanță: %1$s
+ Bearing: %1$s
+ Acest dispozitiv nu are un senzor de busolă. Heading este indisponibil.
+ Este necesară permisiunea de localizare pentru a afișa distanța și rularea.
+ Furnizorul de localizare este dezactivat. Porniți serviciile de localizare
+ Se așteaptă o rezolvare GPS-ul pentru a calcula distanța și rularea.
+ Suprafață estimată: \u00b1%1$s (\u00b1%2$s)
+ Zonă estimată: precizie necunoscută
+ Marchează ca Citit
+ Acum
+ Următoarele canale au fost găsite în codul QR. Selectaţi o dată ce doriţi să adăugaţi pe dispozitivul dumneavoastră. Canalele existente vor fi păstrate.
+ Acest cod QR conţine o configuraţie completă. Aceasta va REPLACE canalele existente şi setările radio existente. Toate canalele existente vor fi eliminate.
+ Încărcare
+ Filtru mesaje
+ Activați filtrarea
+ Ascunde mesajele ce conțin cuvinte filtre
+ Filtrare cuvinte
+ Mesajele ce conţin aceste cuvinte vor fi ascunse
+ Adaugă cuvânt sau regex:pattern-ul
+ Nici un filtru cuvinte configurate
+ Model regex
+ Cuvânt întreg se potrivește
+ Arata %1$d filtrate
+ Ascunde %1$d filtrate
+ Filtrat
+ Activați filtrarea
+ Dezactivați filtrarea
+ Adresa canalului
+ Scanați NFC
+ Scanare contacte partajate NFC
+ Scanare cod QR contacte partajat
+ Introducere adresă contact partajată
+ Scanare canale NFC
+ Scanează canale cod QR
+ Introduceți URL-ul canalului
+ Distribuie codul QR al canalelor
+ Aduceți dispozitivul aproape de tag-ul NFC pentru a scana.
+ Generați codul QR
+ NFC este dezactivat. Vă rugăm să îl activați în setările de sistem.ToateBluetooth
+ Configuraţi permisiunile Bluetooth
+ Descoperiți
+ Gestionați fără fir setările și canalele dispozitivului dvs.
+ Selecție stil hartă
+ Baterie: %1$d%%
+ Noduri: %1$d online / %2$d total
+ Actualizare: %1$s
+ ChUtil: %1$s% | AirTX: %2$s%
+ Trafic: TX %1$d / RX %2$d (D: %3$d)
+ Relee: %1$d (Canceled: %2$d)
+ Diagnosticuri: %1$s
+ Zgomotul %1$d dBm
+ Greșit %1$d
+ A pierdut %1$d
+ Titlu
+ %1$d / %2$d
+ %1$s
+ Alimentare
+ Reimprospatare
+ Actualizat
+ Adaugă nivel rețea
+ Fișier local MBTiles
+ Adaugă fișier MBTiles local
+ TAK (ATAK)
+ Configurare TAK
+ Activare server TAK local
+ Pornește un server TCP pe portul 8089 pentru conexiunile ATAK
+ Culoarea echipei
+ Rolul membrului
+ Nespecificat
+ Alb
+ Galben
+ Portocaliu
+ MovRoșu
+ Maro
+ Violet
+ Albastru închisAlbastru
+ Azuriu
+ Albastru-verzuiVerde
+ Verde închis
+ Maro
+ Nespecificat
+ Membrii Echipei
+ Lider de echipă
+ Sediul Principal
+ Lunetist
+ Medic
+ Retrimite observatorul
+ Operator Radio Telefon
+ caine
+ Gestionare trafic
+ Modul activat
+ Deduplicare poziție
+ Precizie poziție (bits)
+ Interval poziţie minimă (sec)
+ NodeInfo Răspuns direct
+ Hops maxim pentru răspuns direct
+ Evaluare limitare
+ Evaluează fereastra limită (secunde)
+ Pachete Max în fereastră
+ Plasează pachete necunoscute
+ Prag de pachet necunoscut
+ Telemetrie doar local
+ Poziție doar-locală (raioane)
+ Păstrează Hops Router
+ Notiță
+ Dispozitiv de stocare & UI (doar cu permisiune)
+ Tema %1$s, Limba %2$s
+ Fișiere disponibile (%1$d):
+ - %1$s (%2$d bytes)
+ Nici un fişier manifestat.
+ Conectare
+ Gata
+ Wi-Fi Provisioning for mPWRD-OS
+ Furnizați acreditări Wi-Fi pentru dispozitivul mPWRD-OS prin Bluetooth.
+ Aflați mai multe despre proiectul mPWRD-OS\nhttps://github.com/mPWRD-OS
+ Căutare dispozitive
+ Dispozitiv gasit
+ Gata de scanare pentru rețele WiFi.
+ Scanare pentru reţele
+ Scanare…
+ Se aplică configurarea WiFi…
+ Nu au fost găsite rețele
+ Nu se poate conecta: %1$s
+ Nu s-a reușit scanarea pentru rețelele WiFi: %1$s
+ %1$d%
+ Rețele disponibile
+ Nume rețea (SSID)
+ 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 b414c046c..8d4590e82 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -257,10 +257,15 @@
Сброс значений по умолчаниюПрименитьТема
+ КонтрастностьСветлаяТемнаяПо умолчаниюВыберите тему
+ Уровень контрастности
+ Стандартный
+ Средний
+ ВысокийПредоставление местоположения для сетиКомпактная кодировка кириллицы
@@ -308,6 +313,7 @@
Доставка подтвержденаВаше устройство может отключиться и перезагрузиться во время применения настроек.Ошибка
+ Неизвестная ошибкаИгнорироватьУдалить из игнорируемыхДобавить '%1$s' в список игнорируемых?
@@ -359,9 +365,9 @@
БатареяChUtilAirUtil
- %1$s: %2$.1f%%
- %1$s: %2$.1f В
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s В
+ %1$s%1$s: %2$sТемпВлажн
@@ -383,12 +389,9 @@
Пользовательская информацияУведомления о новых нодахСигнал/шум
- Соотношение сигнал/шум, мера, используемая в коммуникациях для количественной оценки уровня желаемого сигнала по отношению к уровню фонового шума. В Meshtastic и других беспроводных системах более высокий SNR указывает на более четкий сигнал, который может повысить надежность и качество передачи данных.RSSI
- Индикатор уровня принимаемого сигнала, измерение, используемое для определения уровня мощности, принимаемой антенной. Более высокое значение RSSI обычно указывает на более сильное и стабильное соединение.(Качество воздуха в помещении) Относительная шкала IAQ, измеренная Bosch BME680. Диапазон значений 0–500.Интервал передачи
- Карта нодМестоположениеОбновление последнего местоположенияМетрики окружения
@@ -437,7 +440,6 @@
1ММаксМин
- СредРазвернуть диаграммуСвернуть диаграммуНеизвестный возраст
@@ -589,6 +591,9 @@
Продолжительность вывода (миллисекунды)Таймаут Nag (в секундах)Рингтон
+ Импортировать рингтон
+ Файл пуст
+ Ошибка импорта: %1$sВоспроизвестиИспользовать I2S как буззерLoRa
@@ -612,6 +617,23 @@
Игнорировать MQTTОК в MQTTНастройка MQTT
+ Неактивно
+ Отключено
+ Отключено — %1$s
+ Подключение...
+ Подключено
+ Переподключение...
+ Переподключение (попытка %1$d) — %2$s
+ Проверить соединение
+ Проверяем брокер…
+ Доступно. Брокер принял учетные данные.
+ Доступно (%1$s)
+ Брокер отклонен: %1$s
+ Узел не найден
+ Не удается подключиться к брокеру (TCP)
+ Сбой TLS-рукопожатия
+ Тайм-аут после %1$d мс
+ Соединение не удалосьMQTT включенАдресИмя пользователя
@@ -727,6 +749,7 @@
Дождь (24ч)ВесРадиация
+ Темп. 1-WireКачество воздуха в помещении (IAQ)URL-адрес
@@ -1214,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ľúčaNotifikácie nových uzlovSNR
- 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 uzlovPozíciaAdministráciaAdministrácia na diaľku
@@ -362,6 +359,8 @@
LoRaŠírka pásmaRegión
+ Odpojené
+ PripojenýAdresaPoužívateľské menoHeslo
@@ -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čaObvestila 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ščAdministracijaAdministracija na daljavoSlab
@@ -226,6 +223,7 @@
KopirajZnak opozorilnega zvonca!Regija
+ PrekinjenoJavni 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 PublikPërputhje e Gabuar e Çelësit PublikNjoftimet 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ësAdministratëAdministratë e LargëtI Keq
@@ -202,6 +199,7 @@
Hops drejt %1$d Hops prapa %2$d訊息Rajon
+ I shkëputurKoha e skaduarDistanca
@@ -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 29b856819..a365fc888 100644
--- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
@@ -151,6 +151,7 @@
ТамнаПрати системОдабери тему
+ СтандардноОбезбедите локацију телефона меш мрежиОбриши поруку?
@@ -240,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
@@ -348,6 +346,8 @@
Игнориши MQTTПозитиван за MQTTMQTT подешавања
+ Raskačeno
+ Блутут повезанАдресаКорисничко имеЛозинка
@@ -429,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 13135d394..5bfbb0a84 100644
--- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
@@ -151,6 +151,7 @@
ТамнаПрати системОдабери тему
+ СтандардноОбезбедите локацију телефона меш мрежиОбриши поруку?
@@ -240,12 +241,9 @@
Неусаглашеност јавних кључеваОбавештења о новим чворовимаSNR
- Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података.RSSI
- Индикатор јачине примљеног сигнала RSSI је мера која се користи за одређивање нивоа снаге која се прима преко антене. Виша вредност RSSI генерално указује на јачу и стабилнију везу.Индекс квалитета ваздуха (IAQ) као мера за одређивање квалитета ваздуха унутрашњости, мерен са Bosch BME680. Вредности се крећу у распону од 0 до 500.Метрика уређаја
- Мапа чвороваПозицијаМетрике сензораАдминистрација
@@ -348,6 +346,8 @@
Игнориши MQTTПозитиван за MQTTMQTT подешавања
+ Раскачено
+ Блутут повезанАдресаКорисничко имеЛозинка
@@ -429,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 da0bb8d4f..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ändInväntar kvittensKvittens köad
+ Levererad till nätOkändKvitteradIngen rutt
@@ -272,6 +273,7 @@
Nollställ NodeDBSändning bekräftadFel
+ Okänt felIgnoreraTa bort från ignoreradeLägg till '%1$s' på ignorera-listan? Din radioenhet kommer att starta om efter denna ändring.
@@ -338,12 +340,9 @@
AnvändarinfoNy nod aviseringSNR
- 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 kartaPlatsSenaste positionsuppdateringMiljövärden
@@ -372,6 +371,7 @@
Varaktighet: %1$s sRutt spårad mot destination:\n\nRutten spårad tillbaka till oss:\n\n
+ Inget svar1h24T1V
@@ -528,6 +528,10 @@
Ignorera MQTTOk till MQTTMQTT-konfiguration
+ Frånkopplad
+ Ansluten
+ Testa anslutningen
+ Anslutningen misslyckadesMQTT är aktiveratAdressAnvändarnamn
@@ -944,4 +948,7 @@
Modul aktiveradAnslutKlart
+ 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 bildirimleriSNR
- 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ıKonumYönetimUzaktan Yönetim
@@ -366,6 +363,8 @@
PA fanı devre dışıMQTT'yi YoksayMQTT Yapılandırması
+ Bağlantı kesildi
+ BağlandıMQTT etkinAdresKullanıcı adı
@@ -546,4 +545,6 @@
MaviYeşilBağ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 2c885d5e5..c9a86af43 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -222,6 +222,7 @@
Очищення бази вузлівДоставку підтвердженоПомилка
+ Невідома помилкаІгноруватиВилучити з ігнорованихДодати '%1$s' до чорного списку? Після цієї зміни ваш пристрій перезавантажиться.
@@ -276,9 +277,7 @@
Сповіщення про нові вузлиSNRRSSI
- Показник рівня потужності сигналу — вимірювання, що використовується для визначення рівня потужності, що приймається антеною. Вище значення RSSI зазвичай вказує на міцніше та стабільніше з'єднання.Показники пристрою
- Мапа вузлівМісцезнаходженняПоказники довкілляАдміністрування
@@ -400,6 +399,9 @@
Перевизначити частотуІгнорувати MQTTНалаштування MQTT
+ Відключено
+ Під’єднано
+ Перевірка зʼєднанняMQTT увімкненийАдресаІм'я користувача
@@ -720,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 f7c3d5e92..7fff0db20 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -243,6 +243,7 @@
深色系统默认设置选择主题
+ 标准向网格提供手机位置紧凑的Cyrillic编码
@@ -289,6 +290,7 @@
已送达在应用设置时,您的设备可能会断开连接并重启。错误
+ 未知错误忽略从忽略中删除添加 '%1$s' 到忽略列表?
@@ -338,9 +340,7 @@
电池ChUtilAirUtil
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s%1$s: %2$s温度湿度
@@ -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使用MQTTMQTT设置
+ 已断开连接
+ 已连接
+ 连接测试启用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 fb6856a0e..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,15 @@
恢復預設設置套用主題
+ 對比度淺色深色系統預設選擇主題
+ 對比度等級
+ 標準
+ 中等
+ 高將手機位置提供給Mesh網路使用同形異意字元編碼處理西里爾字母
@@ -273,6 +302,7 @@
已確認送達在設定套用的過程中,您的裝置可能會斷開連線並重新啟動。錯誤
+ 未知錯誤忽略從忽略清單中移除將 '%1$s' 加入忽略清單嗎?
@@ -313,6 +343,8 @@
目前:永久靜音未靜音
+ 已靜音 %1$d 天 %2$s 小時
+ 已靜音 %1$s 小時將「%1$s」的通知設為靜音?取消「%1$s」的通知靜音?替換
@@ -322,6 +354,10 @@
電池頻道利用率空中時間使用率
+ %1$s:%2$s%%
+ %1$s:%2$s%V
+ %1$s
+ %1$s:%2$s溫度濕度土壤溫度
@@ -342,12 +378,9 @@
使用者資訊新節點通知SNR
- 信噪比(SNR),用於通訊中量化所需信號與背景噪音水平的指標。在 Meshtastic 及其他無線系統中,信噪比越高表示信號越清晰,可以提高數據傳輸的可靠性和品質。RSSI
- 接收信號強度指示(RSSI)用於測量天線所接收到信號的功率強度。 RSSI 值越高通常代表連線越強且穩定。(室內空氣品質) 相對尺度 IAQ 值,由 Bosch BME680 測量。值範圍 0–500。裝置計量資料
- 節點地圖位置最後位置更新環境計量資料
@@ -375,12 +408,26 @@
持續時間:%1$s 秒追蹤至目的地的路由:\n\n追蹤回到本機的路由:\n\n
+ 去程跳數
+ 回程跳數
+ 來回跳數
+ 無回應
+ 1分鐘負載
+ 5分鐘負載
+ 15分鐘負載
+ 1分鐘系統負載平均值
+ 5分鐘系統負載平均值
+ 15分鐘系統負載平均值
+ 可用系統記憶體(位元組)1小時二十四小時一週二週1個月最大值
+ 最小
+ 展開圖表
+ 收起圖表未知年齡複製警鈴字符!
@@ -394,11 +441,17 @@
頻道1頻道2頻道3
+ 頻道 4
+ 頻道 5
+ 頻道 6
+ 頻道 7
+ 頻道 8當前電壓你確定嗎?設備角色檔案和關於選擇正確的設備角色的博客文章 。]]>我知道我在做什麼。
+ 節點 %1$s 電量過低 (%2$d%)低電量通知低電量:%1$s低電量通知(收藏節點)
@@ -524,6 +577,9 @@
輸出持續時間(毫秒)通知逾時時間(秒)鈴聲
+ 已匯入鈴聲
+ 檔案為空
+ 匯入錯誤:%1$s播放使用 I2S 控制蜂鳴器LoRa
@@ -547,6 +603,23 @@
無視MQTT允許轉發至 MQTTMQTT配置
+ 已停用
+ 已中斷連線
+ 已斷線 — %1$s
+ 正在連接…
+ 已連線
+ 重新連接中…
+ 重新連接中(第 %1$d 次嘗試) — %2$s
+ 測試連線
+ 正在查詢 Broker…
+ 可供連線,Broker 已驗證並接受憑證。
+ 可供連線(%1$s)
+ Broker 遭拒:%1$s
+ 找不到伺服器
+ 無法連線至 Broker 中繼伺服器(TCP)
+ TLS 握手失敗
+ 經過 %1$d 毫秒後逾時
+ 測試失敗啟用MQTT服務器地址用戶名
@@ -618,6 +691,8 @@
啟用序列埠啟用 Echo序列埠鮑率
+ RX
+ TX逾時序列埠模式覆蓋控制台序列埠
@@ -652,8 +727,15 @@
距離照度風速
+ 風速
+ 陣風
+ 風停
+ 風向
+ 降雨(1h)
+ 降雨(24h)重量輻射
+ 1-Wire 溫度室內空氣品質 (IAQ)網址
@@ -670,6 +752,7 @@
時間戳記航向速度
+ %1$d Km/h衛星數海拔頻率
@@ -735,6 +818,11 @@
顯示路徑顯示定位精準度客户端通知
+ 金鑰驗證
+ 金鑰驗證請求
+ 金鑰驗證已完成
+ 偵測到重複的公鑰
+ 偵測到加密金鑰強度不足偵測到金鑰已洩漏,點選確定後重新產生金鑰。重新產生私鑰您確定要重新產生密鑰嗎?\n\n連線過的其他節點需要刪除並重新交換金鑰後才能恢復加密通訊連線。
@@ -787,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 設定藍牙裝置連接裝置超過速率限制,請稍後再嘗試。
@@ -908,6 +1003,7 @@
穩定版Alpha 測試版注意:更新期間將會暫時中斷您的裝置連線。
+ 正在下載韌體... %1$d%錯誤: %1$s重試更新成功!
@@ -950,6 +1046,7 @@
版本說明未知錯誤缺少節點使用者資訊。
+ 電量過低 (%1$d%%),請在更新前為您的裝置充電。無法取得韌體檔案。USB 更新失敗韌體雜湊值遭拒。裝置可能需要雜湊值配置或開機載入程式更新。
@@ -1023,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
@@ -1043,6 +1142,8 @@
新增本機 MBTiles 檔案TAK (ATAK)TAK 設定
+ 啓用本地 TAK 伺服器
+ 在 8089 埠啟動一個用於 ATAK 連線的 TCP 伺服器隊伍顏色隊員角色未指定
@@ -1086,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 5d7eba25a..505d80821 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -278,10 +278,15 @@
Reset to defaultsApplyTheme
+ ContrastLightDarkSystem defaultChoose theme
+ Contrast level
+ Standard
+ Medium
+ HighProvide phone location to meshCompact encoding for Cyrillic
@@ -327,6 +332,7 @@
Delivery confirmedYour device may disconnect and reboot while settings are applied.Error
+ Unknown errorIgnoreRemove from ignoredAdd '%1$s' to ignore list?
@@ -378,9 +384,9 @@
BatteryChUtilAirUtil
- %1$s: %2$.1f%%
- %1$s: %2$.1f V
- %1$.1f
+ %1$s: %2$s%%
+ %1$s: %2$s V
+ %1$s%1$s: %2$sTempHum
@@ -402,12 +408,9 @@
User InfoNew node notificationsSNR
- 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 MapPositionLast position updateEnvironment Metrics
@@ -454,7 +457,6 @@
1MMaxMin
- AvgExpand chartCollapse chartUnknown Age
@@ -606,6 +608,9 @@
Output duration (milliseconds)Nag timeout (seconds)Ringtone
+ Imported ringtone
+ File is empty
+ Error importing: %1$sPlayUse I2S as buzzerLoRa
@@ -629,6 +634,23 @@
Ignore MQTTOk to MQTTMQTT 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 failedMQTT enabledAddressUsername
@@ -744,6 +766,7 @@
Rain (24h)WeightRadiation
+ 1-Wire TempIndoor Air Quality (IAQ)URL
@@ -1251,4 +1274,22 @@
Enter or select a networkWiFi 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/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt
index 91eb97484..8b939fa9b 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt
@@ -16,9 +16,11 @@
*/
package org.meshtastic.core.service
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
+import org.meshtastic.core.di.CoroutineDispatchers
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@@ -27,10 +29,15 @@ import kotlin.test.assertNotNull
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AndroidFileServiceTest {
+ private val testDispatchers =
+ UnconfinedTestDispatcher().let { dispatcher ->
+ CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
+ }
+
@Test
fun testInitialization() = runTest {
val context = RuntimeEnvironment.getApplication()
- val service = AndroidFileService(context)
+ val service = AndroidFileService(context, testDispatchers)
assertNotNull(service)
}
}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt
index 010fcdc89..8924cdcc8 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt
@@ -18,7 +18,7 @@ package org.meshtastic.core.service
import android.app.Application
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
+import com.eygraber.uri.toAndroidUri
import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.BufferedSource
@@ -26,15 +26,16 @@ import okio.buffer
import okio.sink
import okio.source
import org.koin.core.annotation.Single
-import org.meshtastic.core.common.util.MeshtasticUri
-import org.meshtastic.core.common.util.toAndroidUri
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import java.io.FileOutputStream
@Single
-class AndroidFileService(private val context: Application) : FileService {
- override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
- withContext(Dispatchers.IO) {
+class AndroidFileService(private val context: Application, private val dispatchers: CoroutineDispatchers) :
+ FileService {
+ override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean =
+ withContext(dispatchers.io) {
try {
val pfd = context.contentResolver.openFileDescriptor(uri.toAndroidUri(), "wt")
if (pfd == null) {
@@ -51,8 +52,8 @@ class AndroidFileService(private val context: Application) : FileService {
}
}
- override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
- withContext(Dispatchers.IO) {
+ override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean =
+ withContext(dispatchers.io) {
try {
val success =
context.contentResolver.openInputStream(uri.toAndroidUri())?.use { inputStream ->
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
index 966569f4f..36c26c879 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
@@ -20,12 +20,12 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.PacketRepository
@@ -38,7 +38,9 @@ class MarkAsReadReceiver :
private val serviceNotifications: MeshServiceNotifications by inject()
- private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ"
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index 028030f76..5869ce94f 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -25,11 +25,11 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koin.android.ext.android.inject
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.toRemoteExceptions
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser
@@ -84,8 +84,10 @@ class MeshService : Service() {
private val router: MeshRouter by inject()
+ private val dispatchers: CoroutineDispatchers by inject()
+
private val serviceJob = Job()
- private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+ private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) }
private var isServiceInitialized = false
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
index cff4ec041..211e3b9c4 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
@@ -267,7 +267,8 @@ class MeshServiceNotificationsImpl(
enableLights(true)
enableVibration(true)
setBypassDnd(true)
- val alertSoundUri = "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.alert}".toUri()
+ val alertSoundUri =
+ "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.meshtastic_alert}".toUri()
setSound(
alertSoundUri,
AudioAttributes.Builder()
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
index 5965b9ddd..f4db74403 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
@@ -21,11 +21,11 @@ import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.ServiceRepository
@@ -41,7 +41,9 @@ class ReactionReceiver :
private val serviceRepository: ServiceRepository by inject()
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) }
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override fun onReceive(context: Context, intent: Intent) {
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index 4e82a735d..d7a943783 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -21,11 +21,11 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -44,7 +44,9 @@ class ReplyReceiver :
private val meshServiceNotifications: MeshServiceNotifications by inject()
- private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION"
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt
index 57408cff1..22bacf43a 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt
@@ -133,7 +133,7 @@ class ServiceBroadcasts(private val context: Context, private val serviceReposit
explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED))
}
- // Restore legacy action for other consumers (e.g. mesh_service_example)
+ // Restore legacy action for other consumers (e.g. ATAK plugins)
val legacyIntent =
Intent(ACTION_CONNECTION_CHANGED).apply {
putExtra(EXTRA_CONNECTED, stateStr)
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 e651d95ce..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,16 @@ 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
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -57,18 +60,16 @@ class MeshServiceOrchestrator(
private val takMeshIntegration: TAKMeshIntegration,
private val takPrefs: TakPrefs,
private val databaseManager: DatabaseManager,
- @Named("ServiceScope") private val scope: CoroutineScope,
+ private val connectionManager: MeshConnectionManager,
+ 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.
@@ -83,26 +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
@@ -116,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()
}
@@ -139,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 48be7dbf6..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,18 +23,21 @@ 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
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -67,9 +70,13 @@ class MeshServiceOrchestratorTest {
private val takPrefs: TakPrefs = mock(MockMode.autofill)
private val cotHandler: CoTHandler = mock(MockMode.autofill)
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(
@@ -111,7 +118,8 @@ class MeshServiceOrchestratorTest {
takMeshIntegration = takMeshIntegration,
takPrefs = takPrefs,
databaseManager = databaseManager,
- scope = testScope,
+ connectionManager = connectionManager,
+ dispatchers = dispatchers,
)
}
@@ -214,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/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt
index 8f8e08d45..5b3d6df0d 100644
--- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt
+++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt
@@ -17,7 +17,6 @@
package org.meshtastic.core.service
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.BufferedSource
@@ -25,17 +24,18 @@ import okio.buffer
import okio.sink
import okio.source
import org.koin.core.annotation.Single
-import org.meshtastic.core.common.util.MeshtasticUri
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import java.io.File
@Single
-class JvmFileService : FileService {
- override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
- withContext(Dispatchers.IO) {
+class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileService {
+ override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean =
+ withContext(dispatchers.io) {
try {
- // Treat uriString as a local file path
- val file = File(uri.uriString)
+ // Treat URI string as a local file path
+ val file = File(uri.toString())
file.parentFile?.mkdirs()
file.sink().buffer().use { sink -> block(sink) }
true
@@ -45,10 +45,10 @@ class JvmFileService : FileService {
}
}
- override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
- withContext(Dispatchers.IO) {
+ override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean =
+ withContext(dispatchers.io) {
try {
- val file = File(uri.uriString)
+ val file = File(uri.toString())
file.source().buffer().use { source -> block(source) }
true
} catch (e: Exception) {
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
index cd616417d..732d03064 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt
@@ -20,47 +20,41 @@ package org.meshtastic.core.takserver
import kotlin.time.Instant
-fun CoTMessage.toXml(): String {
- val sb = StringBuilder()
- sb.append(
+fun CoTMessage.toXml(): String = buildString {
+ append(
"",
)
contact?.let {
- sb.append(
+ append(
"",
)
}
- group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
+ group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
- status?.let { sb.append("") }
+ status?.let { append("") }
- track?.let { sb.append("") }
+ track?.let { append("") }
if (chat != null) {
val senderUid = uid.geoChatSenderUid()
val messageId = uid.geoChatMessageId()
- sb.append(
+ append(
"<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'>",
)
- sb.append("")
- sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
- sb.append(
+ append("")
+ append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
+ append(
"${chat.message.xmlEscaped()}",
)
} else if (!remarks.isNullOrEmpty()) {
- sb.append("${remarks.xmlEscaped()}")
+ append("${remarks.xmlEscaped()}")
}
- rawDetailXml?.let {
- if (it.isNotEmpty()) {
- sb.append(it)
- }
- }
+ rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) }
- sb.append("")
- return sb.toString()
+ append("")
}
private fun Instant.toXmlString(): String = this.toString()
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/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt
index 65d7077f9..48c635560 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt
@@ -16,12 +16,16 @@
*/
package org.meshtastic.core.takserver.fountain
+import okio.ByteString.Companion.toByteString
+
internal expect object ZlibCodec {
fun compress(data: ByteArray): ByteArray?
fun decompress(data: ByteArray): ByteArray?
}
-internal expect object CryptoCodec {
- fun sha256Prefix8(data: ByteArray): ByteArray
+internal object CryptoCodec {
+ private const val PREFIX_SIZE = 8
+
+ fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE)
}
diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
similarity index 83%
rename from core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
rename to core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
index 4473fc521..b0e4f1030 100644
--- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
+++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
@@ -24,8 +24,6 @@ import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
-import platform.CoreCrypto.CC_SHA256
-import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
import platform.zlib.Z_BUF_ERROR
import platform.zlib.Z_OK
import platform.zlib.compress
@@ -105,20 +103,3 @@ internal actual object ZlibCodec {
return null
}
}
-
-internal actual object CryptoCodec {
- @OptIn(ExperimentalForeignApi::class)
- actual fun sha256Prefix8(data: ByteArray): ByteArray {
- val digest = ByteArray(CC_SHA256_DIGEST_LENGTH)
- if (data.isNotEmpty()) {
- data.usePinned { dataPin ->
- digest.usePinned { digestPin ->
- CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret())
- }
- }
- } else {
- digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) }
- }
- return digest.copyOf(8)
- }
-}
diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
similarity index 90%
rename from core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
rename to core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
index 9db28ac66..fca9f0f52 100644
--- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt
+++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt
@@ -17,7 +17,6 @@
package org.meshtastic.core.takserver.fountain
import java.io.ByteArrayOutputStream
-import java.security.MessageDigest
import java.util.zip.Deflater
import java.util.zip.Inflater
@@ -66,10 +65,3 @@ internal actual object ZlibCodec {
}
}
}
-
-internal actual object CryptoCodec {
- actual fun sha256Prefix8(data: ByteArray): ByteArray {
- val digest = MessageDigest.getInstance("SHA-256")
- return digest.digest(data).copyOf(8)
- }
-}
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 2b9f9918f..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
@@ -84,6 +84,12 @@ class FakeUiPrefs : UiPrefs {
theme.value = value
}
+ override val contrastLevel = MutableStateFlow(0)
+
+ override fun setContrastLevel(value: Int) {
+ contrastLevel.value = value
+ }
+
override val locale = MutableStateFlow("en")
override fun setLocale(languageTag: String) {
@@ -231,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