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 @@ Макс Мин - Ср Разгъване на диаграмата Свиване на диаграмата Неизвестна възраст @@ -468,6 +470,9 @@ Известия при получаване на сигнал/позвъняване Използване на PWM зумер Тон на звънене + Импортирана мелодия + Файлът е празен + Грешка при импортиране: %1$s LoRa Опции Разширени @@ -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 @@ Sempre Traçar ruta Regió + Desconnectat Temps esgotat Distància Meshtastic @@ -199,4 +200,6 @@ + Meshtastic + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index 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ému Vyberte vzhled + Vysoká Poskytnout polohu síti Úsporné kódování pro cyriliku @@ -263,6 +264,7 @@ Doručeno Vaše zařízení se může odpojit a restartovat při aplikaci nastavení. Chyba + Neznámá chyba Ignorovat Odstranit z ignorovaných Přidat '%1$s' do seznamu ignorovaných? @@ -311,6 +313,7 @@ Baterie ChUtil AirUtil + %1$s %1$s: %2$s Teplota Vlhkost @@ -328,12 +331,9 @@ Informace o uživateli Oznámení o nových uzlech SNR - Poměr signálu k šumu (SNR) je veličina používaná k vyjádření poměru mezi úrovní požadovaného signálu a úrovní šumu na pozadí. V Meshtastic a dalších bezdrátových systémech vyšší hodnota SNR značí čistší signál, což může zvýšit spolehlivost a kvalitu přenosu dat. RSSI - Indikátor síly přijímaného signálu, měření, které se používá k určení hladiny výkonu přijímané anténou. Vyšší hodnota RSSI obvykle znamená silnější a stabilnější spojení. (Vnitřní kvalita ovzduší) relativní hodnota IAQ měřená Bosch BME680. Hodnota rozsahu 0–500. Metriky zařízení - Mapa uzlu Pozice Poslední aktualizace pozice Metriky prostředí @@ -522,6 +522,8 @@ Ignorovat MQTT OK do MQTT Nastavení MQTT + Odpojeno + Připojeno MQTT povoleno Adresa Uživatelské jméno @@ -965,4 +967,6 @@ Poznámka Připojit Hotovo + Meshtastic + Filtr diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 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ücksetzen Anwenden Design + Kontrast Hell Dunkel System Design auswählen + Kontrast + Standard + Medium Fast + Hoch Standort zum Mesh angeben Kompakte Kodierung für Kyrillisch @@ -302,6 +307,7 @@ Zustellung bestätigt Ihr Gerät kann die Verbindung trennen und neu starten, während die Einstellungen angewendet werden. Fehler + Unbekannter Fehler Ignorieren Aus Ignorierliste entfernen '%1$s' zur Ignorieren-Liste hinzufügen? @@ -353,9 +359,9 @@ Akku Kanalauslastung Sendezeit - %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 Temperatur Feuchtigkeit @@ -377,12 +383,9 @@ Benutzerinfo Benachrichtigung neue Knoten SNR - Signal-Rausch-Verhältnis, ein in der Kommunikation verwendetes Maß, um den Pegel eines gewünschten Signals im Verhältnis zum Pegel des Hintergrundrauschens zu quantifizieren. Bei Meshtastic und anderen drahtlosen Systemen weist ein höheres SNR auf ein klareres Signal hin, das die Zuverlässigkeit und Qualität der Datenübertragung verbessern kann. RSSI - Indikator für die empfangene Signalstärke, eine Messung zur Bestimmung der von der Antenne empfangenen Leistungsstärke. Ein höherer RSSI-Wert weist im Allgemeinen auf eine stärkere und stabilere Verbindung hin. (Innenluftqualität) relativer IAQ-Wert gemessen von Bosch BME680. Gerätedaten - Standortkarte Knoten Standort Letzte Standortaktualisierung Umweltdaten @@ -429,7 +432,6 @@ 1 Monat Maximal Minimum - Durchschnitt Diagramm einblenden Diagramm ausblenden Alter unbekannt @@ -581,6 +583,9 @@ Ausgabedauer (GPIO) Nervige Verzögerung (Sekunden) Klingelton + Importierter Klingelton + Datei ist leer + Fehler beim Importieren: %1$s Wiedergabe I2S als Buzzer verwenden LoRa @@ -604,6 +609,23 @@ MQTT ignorieren OK für MQTT MQTT Einstellungen + Inaktiv + Verbindung getrennt + Verbindung getrennt - %1$s + Wird verbunden + Verbunden + Erneut verbinden + Erneut verbinden (Versuch %1$d) - %2$s + Verbindung testen + Broker prüfen. + Erreichbar. Broker akzeptierte Anmeldedaten. + Erreichbar (%1$s) + Broker abgelehnt: %1$s + Host nicht gefunden + Broker (TCP) nicht erreichbar + TLS Handshake fehlgeschlagen + Zeitüberschreitung nach %1$d ms + Verbindung fehlgeschlagen MQTT aktiviert Adresse Benutzername @@ -719,6 +741,7 @@ Regen (24 Std.) Gewicht Strahlung + 1-Wire Temperature Luftqualität im Innenbereich (IAQ) URL @@ -1198,4 +1221,21 @@ Netzwerk eingeben oder auswählen WLAN erfolgreich konfiguriert! WLAN Konfiguration konnte nicht angewendet werden + Meshtastic Desktop + Meshtastic anzeigen + Beenden + Meshtastic + TAK Datenpaket exportieren + Zeitzone löschen + Filter + Filter entfernen + Legende für Luftqualität anzeigen + Nachrichtenstatus anzeigen + Antwort senden + Nachricht kopieren + Nachricht auswählen + Nachricht löschen + Mit Emoji reagieren + Gerät auswählen + Wählen Sie ein Netzwerk diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml index 88feab55e..8386ac2ea 100644 --- a/core/resources/src/commonMain/composeResources/values-el/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml @@ -164,6 +164,7 @@ Μηνύματα LoRa Περιφέρεια + Αποσυνδεδεμένο Διεύθυνση Όνομα χρήστη Κωδικός πρόσβασης @@ -200,4 +201,5 @@ Κόκκινο Μπλε Πράσινο + Φίλτρο diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index 7b9ca263e..4c59aa547 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -300,13 +300,10 @@ Clave pública no coincide Notificaciones de nuevo nodo SNR - SNR: Ratio de señal a ruido, una medida utilizada en las comunicaciones para cuantificar el nivel de una señal deseada respecto al nivel del ruido de fondo. En Meshtastic y otros sistemas inalámbricos, un mayor SNR indica una señal más clara que puede mejorar la fiabilidad y la calidad de la transmisión de datos. RSSI - Indicador de Fuerza de Señal Recibida (RSSI en inglés), una medida utilizada para determinar el nivel de potencia que está siendo recibido por la antena. Un valor de RSSI más alto generalmente indica una conexión más fuerte y estable. (Calidad de Aire interior) escala relativa del valor IAQ como mediciones del sensor Bosch BME680. Rango de Valores 0 - 500. Métricas de Dispositivo - Mapa de Nodos Posición Última actualización Métricas de Entorno @@ -492,6 +489,8 @@ Rango de Valores 0 - 500. Ignorar Paquetes MQTT Permitir MQTT Configuración MQTT + Desconectado + Conectado Activar el MQTT Dirección del Servidor MQTT Usuario @@ -836,4 +835,6 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Verde Conectar Hecho + Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index 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ätted Rakenda Teema + Kontrastsus Hele Tume Süsteemi vaikesäte Vali teema + Kontrastsuse tase + Standard + Keskmine + Kõrge Jaga telefoni asukohta mesh-võrku Kompaktne kodeering kirillitsa jaoks @@ -302,6 +307,7 @@ Kohale toimetatud Seadete rakendamise ajal võib seadme ühendus katkeda ja taaskäivituda. Viga + Tundmatu viga Eira Eemalda ignoreeritute hulgast Lisa '%1$s' eiramis loendisse? @@ -353,9 +359,9 @@ Aku Kanali kasutus Saate 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$s Temperatuur Niiskus @@ -377,12 +383,9 @@ Kasutaja teave Uue sõlme teade SNR - Signaali ja müra suhe (SNR) on mõõdik, mida kasutatakse soovitud signaali taseme ja taustamüra taseme vahelise suhte määramisel. Meshtastic ja teistes traadita süsteemides näitab kõrgem signaali ja müra suhe selgemat signaali, mis võib parandada andmeedastuse usaldusväärsust ja kvaliteeti. RSSI - Vastuvõetud signaali tugevuse indikaator (RSSI), mõõt mida kasutatakse antenni poolt vastuvõetava võimsustaseme määramiseks. Kõrgem RSSI väärtus näitab üldiselt tugevamat ja stabiilsemat ühendust. Siseõhu kvaliteet (IAQ) on suhtelise skaala väärtus, näiteks mõõtes Bosch BME680 abil. Väärtuste vahemik 0–500. Seadme mõõdikud - Sõlmede kaart Asukoht Viimase asukoha värskendus Keskkonnamõõdikud @@ -429,7 +432,6 @@ 1k Maksimaalselt Min - Keskm Laienda diagrammi Ahenda diagrammi Tundmatu vanus @@ -581,6 +583,9 @@ Väljundi kestvus (millisekundit) Häire ajalõpp (sekundit) Helin + Imporditud helin + Fail on tühi + Viga importimisel: %1$s Mängi ette Kasuta I2S summerina LoRa @@ -604,6 +609,23 @@ Keela MQTT Ok MQTTi MQTT sätted + Mitteaktiivne + Ühendus katkenud + Ühendus katkenud — %1$s + Ühendan… + Ühendatud + Taas ühendan… + Ühendan uuesti (katse %1$d) — %2$s + Test ühendus + Kontrollin vahendajat… + Ühendus õnnestus. Vahendaja aktsepteeris kasutajateave. + Kättesaadav (%1$s) + Vahendaja lükkas tagasi: %1$s + Hosti ei leitud + Vahendajaga ei saa ühendust (TCP) + TLS ühendus ebaõnnestus + Ajaline katkestus peale %1$d ms + Ühendus ebaõnnestus MQTT lubatud Aadress Kasutajatunnus @@ -719,6 +741,7 @@ Vihm (24h) Kaal Radiatsioon + 1-juhtmeline temperatuur Siseõhu kvaliteet (IAQ) URL @@ -1198,4 +1221,21 @@ Sisestage või valige võrk WiFi edukalt seadistatud! WiFi sätete rakendamine ebaõnnestus + Meshtastic töölaud + Näita Meshtastic + Sule + Kärgvõrgustik + Ekspordi TAK andmepakett + Eemalda ajatsoon + Filtreeri + Eemalda filter + Näita õhukvaliteedi ajalugu + Kuva sõnumi olek + Saada vastus + Kopeeri sõnum + Vali sõnum + Kustuta sõnum + Vasta emotikoniga + Vali seade + Vali võrk diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index 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 oletusasetukset Hyväksy Teema + Kontrasti Vaalea Tumma Järjestelmän oletus Valitse teema + Kontrastin taso + Normaali + Keskitaso + Korkea Jaa puhelimen sijaintitietoa mesh-verkkoon Kyrillisten merkkien tiivis koodaus @@ -302,6 +307,7 @@ Toimitus vahvistettu Laitteesi saattaa katkaista yhteyden ja käynnistyä uudelleen, kun asetuksia otetaan käyttöön. Virhe + Tuntematon virhe Jätä huomiotta Poista huomioimattomista Lisää '%1$s' jätä huomiotta listalle? Laite käynnistyy uudelleen muutoksen tekemisen jälkeen. @@ -353,9 +359,9 @@ Akku Kanavan käyttöaste Lä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$s Lämpötila Kosteus @@ -377,12 +383,9 @@ Käyttäjätiedot Uuden laitteen ilmoitukset SNR - Signaali-kohinasuhde (SNR) on mittari, jota käytetään viestinnässä halutun signaalin tason ja taustahälyn tason määrittämisessä. Meshtasticissa ja muissa langattomissa järjestelmissä korkeampi SNR tarkoittaa selkeämpää signaalia, joka voi parantaa tiedonsiirron luotettavuutta ja laatua. RSSI - Vastaanotetun signaalin voimakkuusindikaattori (RSSI) on mittari, jota käytetään määrittämään antennilla vastaanotetun signaalin voimakkuus. Korkeampi RSSI-arvo yleensä osoittaa vahvemman ja vakaamman yhteyden. Sisäilman laatu (IAQ) on suhteellinen asteikko, jota voidaan mitata mm. Bosch BME680 anturilla ja sen arvoväli on 0–500. Laitteen mittausloki - Laitekartta Sijainti Viimeisin sijainnin päivitys Ympäristöarvot @@ -429,7 +432,6 @@ 1 kk Kaikki Minimi - Keskiarvo Laajenna kaavio Pienennä kaavio Tuntematon ikä @@ -581,6 +583,9 @@ Ulostulon kesto (millisekuntia) Hälytysaikakatkaisu (sekuntia) Soittoääni + Tuotu soittoääni + Tiedosto on tyhjä + Virhe tuotaessa: %1$s Aloita Käytä I2S protokollaa äänimerkille LoRa @@ -604,6 +609,23 @@ Ohita MQTT MQTT päällä MQTT asetukset + Passiivinen + Ei yhdistetty + Yhteys katkaistu — %1$s + Yhdistetään… + Yhdistetty + Yhdistetään uudelleen… + Yhdistetään uudelleen (yritys %1$d) — %2$s + Testaa yhteys + Tarkistetaan välityspalvelinta… + Yhteys onnistui. Välityspalvelin hyväksyi tunnistetiedot. + Yhteys onnistui (%1$s) + Välityspalvelin ei hyväksynyt: %1$s + Palvelinta ei löytynyt + Yhteyttä välityspalvelimeen ei saada (TCP) + TLS-yhteyden muodostus epäonnistui + Aikakatkaistu %1$d ms jälkeen + Yhdistäminen epäonnistui MQTT käytössä Osoite Käyttäjänimi @@ -719,6 +741,7 @@ Sademäärä (24 h) Paino Säteily + Lämpötila (1-Wire) Sisäilmanlaatu (IAQ) URL-osoite @@ -1199,4 +1222,21 @@ Syötä tai valitse verkko WiFi määritetty onnistuneesti! WiFi-asetusten käyttöönotto epäonnistui + Meshtastic työpöytä + Näytä Meshtastic + Lopeta + Meshtastic + Vie TAK-datapaketti + Tyhjennä aikavyöhyke + Suodatus + Poista suodatin + Näytä ilmanlaadun selite + Näytä viestin tila + Lähetä vastaus + Kopioi viesti + Valitse viesti + Poista viesti + Reaktio emojin kanssa + Valitse laite + Valitse verkko diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index 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$s Filtre Effacer le filtre de nœud Filtrer par @@ -40,9 +41,11 @@ Interne par Favoris Afficher uniquement les nœuds ignorés + Exclure MQTT Non reconnu En attente d'accusé de réception En file d'attente pour l'envoi + Délivré au nœud Inconnu Routage via chaîne SF++… Confirmé via chaîne SF++ @@ -119,7 +122,8 @@ Distance minimale en mètres pour considérer une diffusion de position intelligente. À quelle fréquence devrions-nous essayer d'obtenir une position GPS (<10sec le GPS est maintenu allumé). Champs optionnels à inclure dans les messages de position. Plus il y en a, plus le message est grand, plus cela augmentant le temps d'occupation du réseau et le risque de perte. - Sera en veille profonde autant que possible, pour les rôles traqueur et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. + Sera en veille profonde autant que possible, pour les rôles traceurs et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. + Généré à partir de votre clé publique et envoyé à d'autres nœuds sur le maillage pour leur permettre de calculer une clé secrète partagée. Utilisée pour créer une clé partagée avec un appareil distant. Clé publique autorisée à envoyer des messages d’administration à ce nœud. L'appareil est géré par un administrateur de maillage, l'utilisateur ne peut accéder à aucun des paramètres de l'appareil. @@ -163,18 +167,25 @@ Port : Connecté Connexions actuelles : - IP WiFi : + IP du Wifi : IP Ethernet : Connexion en cours Non connecté Aucun appareil sélectionné Périphérique inconnu + Aucun périphérique réseau trouvé + Pas de périphérique USB trouvé + USB + Mode Démo Connecté à la radio, mais en mode veille Mise à jour de l’application requise Vous devez mettre à jour cette application sur l'app store (ou Github). Il est trop vieux pour dialoguer avec le micrologiciel de la radio. Veuillez lire nos docs sur ce sujet. Aucun (désactivé) Notifications de service Remerciements + Bibliothèques Open Source + Meshtastic est construit avec les bibliothèques open source suivantes. Appuyez sur n'importe quelle bibliothèque pour voir sa licence. + %1$d Bibliothèques Cette URL de canal est invalide et ne peut pas être utilisée Panneau de débogage Contenu décodé : @@ -207,7 +218,21 @@ Correspondre à tout | N'importe quel Cela supprimera tous les paquets de journaux et les entrées de la base de données de votre appareil - c'est une réinitialisation complète, et est permanent. Effacer + Rechercher des émojis... + Plus d'actions Canal + %1$s: %2$s + Message de %1$s: %2$s + Entête + Élément %1$d + Pied de page + Exporter le paquet de données TAK + Point + Texte + Jauge + Dégradé + Ceci est un composable personnalisé + Avec plusieurs lignes et styles Statut d'envoi du message Nouveaux messages au-dessous Notifications de message @@ -228,10 +253,15 @@ Rétablir les valeurs par défaut Appliquer Thème + Contraste Clair Sombre Valeur par défaut du système Choisir un thème + Niveau de contraste + Standard + Milieu + Haut Fournir l'emplacement au maillage Encodage compact pour Cyrillique @@ -275,7 +305,9 @@ Message direct Reconfiguration de NodeDB Réception confirmée par le destinataire + Votre appareil peut se déconnecter et redémarrer lorsque les paramètres sont appliqués. Erreur + Une erreur inconnue s'est produite Ignorer Supprimer des ignorés Ajouter '%1$s' à la liste des ignorés ? Votre radio va redémarrer après avoir effectué ce changement. @@ -316,6 +348,8 @@ Actuellement : Toujours muet Non muet + Muet pour %1$d jours, %2$s heures + Muet pour %1$s heures Désactiver les notifications pour '%1$s' ? Réactiver les notifications pour '%1$s' ? Remplacer @@ -325,6 +359,10 @@ Batterie UtilCanal UtilAir + %1$s / %2$s%% + %1$s: %2$s V + %1$s + %1$s: %2$s Temp Hum Temp sol @@ -345,12 +383,9 @@ Infos utilisateur Notifikasyon nouvo nœud SNR - Signal-to-Noise Ratio, une mesure utilisée dans les communications pour quantifier le niveau du signal par rapport au niveau du bruit de fond. Dans les systèmes Meshtastic et autres systèmes sans fil, un SNR plus élevé indique un signal plus clair qui peut améliorer la fiabilité et la qualité de la transmission de données. RSSI - Indicateur de force du signal reçu, une mesure utilisée pour déterminer le niveau de puissance reçu par l'antenne. Une valeur RSSI plus élevée indique généralement une connexion plus forte et plus stable. (Qualité de l'air intérieur) valeur de l'échelle relative IAQ mesurée par Bosch BME680. Plage de valeur 0–500. Métriques de l’appareil - Carte historique des positions Position Dernière mise à jour de position Métriques d'environnement @@ -379,13 +414,26 @@ Durée : %1$s s Route aller :\n\n Route retour :\n\n + Saut vers l'avant + Saut vers l'arrière + Aller/Retour Pas de réponse + Charge 1 m + Charge 5m + Charge 15 m + Moyenne de charge du système d'une minute + Moyenne de charge du système de cinq minutes + Moyenne de charge du système de 15 minutes + Mémoire système disponible en octets 1H 24H 1S 2S 1M Max + Min + Agrandir le graphique + Réduire le graphique Age inconnu Copier Caractère d'appel ! @@ -399,11 +447,17 @@ Canal 1 Canal 2 Canal 3 + Canal 4 + Canal 5 + Canal 6 + Canal 7 + Canal 8 Actif Tension Êtes-vous sûr ? Documentation du rôle de l'appareil et le billet de blog sur comment Choisir le rôle de l'appareil approprié.]]> Je sais ce que je fais. + La batterie du nœud %1$s est faible (%2$d%) Notifications de batterie faible Batterie faible : %1$s Notifications de batterie faible (nœuds favoris) @@ -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$s Lancer Utiliser l'I2S comme buzzer LoRa @@ -552,6 +609,13 @@ Ignorer MQTT Transmission des paquets vers MQTT Configuration MQTT + Inactif + Déconnecté + Connexion… + Connecté + Reconnexion… + Test de la connexion + Échec de la connexion MQTT activé Adresse Nom d'utilisateur @@ -623,6 +687,8 @@ Série activée Écho activé Vitesse de transmission série + RX + Tx Délai d'expiration Mode série Outrepasser le port série de la console @@ -657,8 +723,15 @@ Distance Lux Vent + Vitesse du vent + Rafales de vent + Vent à la traîne + Direction du vent + Pluie (1h) + Pluie (24h) Poids Radiation + Températeur 1-Wire Qualité de l'air intérieur (IAQ) URL @@ -675,6 +748,7 @@ Horodatage En-tête Vitesse + %1$d Km/h Sats Alt Fréq @@ -740,6 +814,11 @@ Afficher les points de repère Afficher les cercles de précision Notification client + Vérification de la clé + Requête de vérification de clé + Vérification de la clé terminée + Clé publique dupliquée détectée + Clé de chiffrement faible détectée Clés compromises détectées, sélectionnez OK pour régénérer. Régénérer la clé privée Êtes-vous sûr de vouloir régénérer votre clé privée ?\n\nLes nœuds qui peuvent avoir précédemment échangé des clés avec ce nœud devront supprimer ce nœud et ré-échanger des clés afin de reprendre une communication sécurisée. @@ -792,7 +871,14 @@ Composer un message Métriques de PAX PAX + PAX : %1$d + B:%1$d + W :%1$d + PAX : %1$s + BLE: %1$s + Wi-Fi : %1$s Aucune métrique PAX disponible. + Approvisionnement Wi-Fi pour mPWRD-OS Appareils Bluetooth Périphérique connecté Limite de débit dépassée. Veuillez réessayer plus tard. @@ -847,6 +933,8 @@ Terrain Hybride Gérer les calques de la carte + Les calques personnalisés prennent en charge les fichiers .kml, .kmz ou GeoJSON. + Aucun calque personnalisé chargé. Ajouter un calque Afficher le calque Supprimer le calque @@ -854,6 +942,10 @@ Nœuds à cet emplacement Type de carte sélectionné Gérer les sources de tuiles personnalisées + Ajouter un réseau de tuile personnalisée + Aucune source de tuiles personnalisées trouvée. + Modifier le réseau de tuile personnalisée + Supprimer le réseau de tuile personnalisée Le nom ne peut pas être vide. Le nom du fournisseur existe déjà. URL ne peut être vide. @@ -947,6 +1039,7 @@ Notes de Version Une erreur inconnue s'est produite Les informations de l'utilisateur du nœud sont manquantes. + Batterie trop faible (%1$d%). Veuillez charger votre appareil avant de mettre à jour. Impossible de récupérer le fichier firmware. Échec de la mise à jour USB Intégrité (hash) du firmware rejetée. Veuillez réessayer ou mettre à jours l'appareil via USB. @@ -1023,8 +1116,10 @@ Configuration Gérer à distance sans fil les paramètres et les canaux de votre appareil. Sélection du style de carte + Batterie : %1$d% Nœuds : %1$d en ligne / %2$d au total Temps de disponibilité : %1$s + ChUtil: %1$s% | AirTX: %2$s% Trafic : TX %1$d / RX %2$d (D: %3$d) Relais : %1$d (annulé: %2$d) Diagnostiques : %1$s @@ -1038,10 +1133,94 @@ Actualiser Mis à jour + Ajouter une couche de réseau + Fichier local MBTiles + Ajouter un fichier local MBTiles + TAK (ATAK) + Configuration TAK + Activer le serveur TAK local + Démarre un serveur TCP sur le port 8089 pour les connexions ATAK + Couleur de l'équipe + Rôle Membre + Non spécifié + Blanc + Jaune + Orange + Magenta Rouge + Marron + Pourpre + Bleu foncé Bleu + Cyan + Turquoise Vert + Vert Foncé + Marron + Non spécifié + Membre de l'équipe + Chef d'équipe + Quartier général + Tireur d'élite + Medic + Observateur de transfert + Opérateur de radio téléphonie + Doggo (K9) + Gestion du trafic + Configuration de la gestion du trafic Module activé + Déduplication de Position + Précision de position (octets) + Intervalle de position min (secs) + Réponse directe de NodeInfo + Max de saut pour une réponse directe + Limitation de débit + Fenêtre de limitation de taux (secs) + Paquets maximum dans la fenêtre + Ignorer les paquets inconnus + Seuil de paquets inconnu + Télémétrie locale uniquement (Relays) + Position locale uniquement (Relays) + Conserver les sauts du Routeur + Note + Stockage de l'appareil & UI (lecture seule) + Thème %1$s, Langue %2$s + Fichiers disponibles (%1$d ) : + - %1$s (%2$d octets) + Aucun fichier affiché. Connecter Terminé + Approvisionnement Wi-Fi pour mPWRD-OS + Fournissez les identifiants Wi-Fi à votre appareil mPWRD-OS via Bluetooth. + En savoir plus sur le projet mPWRD-OS\nhttps://github.com/mPWRD-OS + Recherche de l'appareil + Appareil détecté + Prêt à rechercher des réseaux WiFi. + Rechercher des réseaux + Recherche… + Application de la configuration WiFi… + Aucun réseau trouvé + Impossible de se connecter : %1$s + Échec de la recherche des réseaux WiFi : %1$s + %1$d% + Réseaux disponibles + Nom du réseau (SSID) + Saisir ou sélectionnez un réseau + WiFi configuré avec succès ! + Impossible d'appliquer la configuration WiFi + Meshtastic application de bureau + Afficher Meshtastic + Quitter + Meshtastic + Exporter le paquet de données TAK + Filtre + Supprimer le filtre + Afficher le statut du message + Envoyer une réponse + Copier le message + Sélectionner le message + Supprimer le message + Réagir avec un emoji + Sélectionner l'appareil + Sélectionner le réseau diff --git a/core/resources/src/commonMain/composeResources/values-ga/strings.xml b/core/resources/src/commonMain/composeResources/values-ga/strings.xml index a081daff2..baabf41d0 100644 --- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml @@ -190,10 +190,7 @@ Cóid Poiblí Eochair Mícomhoiriúnacht na heochrach phoiblí Fógartha faoi na nodes nua - Ráta Sigineal go Torann, tomhas a úsáidtear i gcomhfhreagras chun an leibhéal de shígnéil inmhianaithe agus torann cúlra a mheas. I Meshtastic agus i gcórais gan sreang eile, ciallaíonn SNR níos airde go bhfuil sígneál níos soiléire ann agus ábalta méadú ar chreideamh agus cáilíocht an tarchur sonraí. - Táscaire Cumhachta Athnuachana Aithint an Aoise, tomhas a úsáidtear chun leibhéal cumhachta atá faighte ag an antsnáithe a mheas. Léiríonn RSSI níos airde gnóthachtáil níos laige atá i gceangal seasmhach agus níos láidre. (Cáilíocht Aeir Inmheánach) scála ábhartha den luach QAÍ a thomhas ag Bosch BME680. Scála Luach 0–500. - Léarscáil an Node Rialachas Rialú iargúlta Go dona @@ -213,6 +210,7 @@ Céimeanna i dtreo %1$d Céimeanna ar ais %2$d Réigiún + Na ceangailte Am tráth Sáth @@ -229,4 +227,5 @@ + Scagaire diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml index cc3c02597..dc751d2e9 100644 --- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml @@ -149,6 +149,7 @@ Sempre Traza-ruta Rexión + Desconectado Distancia @@ -164,4 +165,5 @@ + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml index 3afe39071..502d64056 100644 --- a/core/resources/src/commonMain/composeResources/values-he/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml @@ -133,6 +133,7 @@ בדיקת מסלול הודעות אזור + מנותק מרחק הגדרות @@ -147,4 +148,5 @@ + פילטר diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml index f049338ae..114c3ed9a 100644 --- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml @@ -150,6 +150,7 @@ Detalji Crveno Regija + Odspojeno Udaljenost Meshtastic @@ -167,4 +168,6 @@ Crveno + Meshtastic + Filtriraj diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml index 7c4fc0f24..60e00d491 100644 --- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml @@ -186,10 +186,7 @@ Chifreman Kle Piblik Pa matche kle piblik Notifikasyon nouvo nœud - Rapò Siynal sou Bri, yon mezi ki itilize nan kominikasyon pou mezire nivo siynal vle a kont nivo bri ki nan anviwònman an. Nan Meshtastic ak lòt sistèm san fil, yon SNR pi wo endike yon siynal pi klè ki ka amelyore fyab ak kalite transmisyon done. - Endikatè Fòs Siynal Resevwa, yon mezi ki itilize pou detèmine nivo pouvwa siynal ki resevwa pa antèn nan. Yon RSSI pi wo jeneralman endike yon koneksyon pi fò ak plis estab. (Kalite Lèy Entèryè) echèl relatif valè IAQ jan li mezire pa Bosch BME680. Ranje valè 0–500. - Kat Nœud Administrasyon Administrasyon Remote Move @@ -201,6 +198,7 @@ Direk Hops vèsus %1$d Hops tounen %2$d Rejyon + Dekonekte Tan pase Distans @@ -217,4 +215,5 @@ + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml index c8d27cf4a..33b795a7f 100644 --- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml @@ -316,12 +316,9 @@ Publikus kulcs nem egyezik Új állomás értesítések SNR - Jel–zaj arány (SNR): a kommunikációban a kívánt jel szintjének és a háttérzaj szintjének aránya. A Meshtastic és más vezeték nélküli rendszerek esetében a magasabb SNR tisztább jelet jelent, ami javítja az adatátvitel megbízhatóságát és minőségét. RSSI - Vett jelerősség-mutató (RSSI): az antenna által vett jel teljesítményszintjének mérése. A magasabb RSSI általában erősebb, stabilabb kapcsolatot jelez. (Beltéri levegőminőség) relatív IAQ érték a Bosch BME680 szenzor alapján. Értéktartomány: 0–500. Eszközmetrikák - Állomás Térkép Pozíció Utolsó pozíciófrissítés Környezeti metrikák @@ -515,6 +512,8 @@ MQTT figyelmen kívül hagyása MQTT-re továbbítható MQTT beállítások + Szétkapcsolva + Csatlakoztatva MQTT engedélyezve Cím Felhasználónév @@ -850,4 +849,6 @@ Kék Zöld Csatlakozás + Meshtastic + Filter diff --git a/core/resources/src/commonMain/composeResources/values-is/strings.xml b/core/resources/src/commonMain/composeResources/values-is/strings.xml index 4e07e1c2a..ce8853250 100644 --- a/core/resources/src/commonMain/composeResources/values-is/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-is/strings.xml @@ -119,6 +119,7 @@ Hámarsksendingartíma náð. Ekki hægt að senda skilaboð, vinsamlegast reynið aftur síðar. Ferilkönnun Svæði + Aftengd diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index 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 @@ Scuro Predefinito di sistema Scegli tema + Medium + Alto Fornire la posizione alla mesh Codifica compatta per cirillico @@ -284,6 +286,7 @@ Consegna confermata Il dispositivo potrebbe disconnettersi e riavviarsi durante l'applicazione delle impostazioni. Errore + Errore sconosciuto Ignora Rimuovi da ignorati Aggiungere '%1$s' alla lista degli ignorati? @@ -352,12 +355,9 @@ Informazioni Utente Notifiche di nuovi nodi SNR - Rapporto segnale-rumore (Signal-to-Noise Ratio), una misura utilizzata nelle comunicazioni per quantificare il livello di un segnale desiderato rispetto al livello di rumore di fondo. In Meshtastic e in altri sistemi wireless, un SNR più elevato indica un segnale più chiaro che può migliorare l'affidabilità e la qualità della trasmissione dei dati. RSSI - Indicatore di forza del segnale ricevuto (Received Signal Strength Indicator), una misura utilizzata per determinare il livello di potenza ricevuto dall'antenna. Un valore RSSI più elevato indica generalmente una connessione più forte e più stabile. (Qualità dell'aria interna) scala relativa del valore della qualità dell'aria indoor, misurato da Bosch BME680. Valore Intervallo 0–500. Metriche Dispositivo - Mappa Dei Nodi Posizione Aggiornamento ultima posizione Metriche Ambientali @@ -557,6 +557,8 @@ Ignora MQTT OK per MQTT Configurazione MQTT + Disconnesso + Connesso MQTT abilitato Indirizzo Username @@ -957,4 +959,6 @@ Note Connetti Fatto + Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml index 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šimas SNR RSSI - Įtaisų žemėlapis Administravimas Nuotolinis administravimas Silpnas @@ -217,6 +216,7 @@ Skambučio simbolis! Raudona Regionas + Atsijungta Viešasis raktas Privatus raktas Baigėsi laikas @@ -237,4 +237,5 @@ Raudona + Filtras diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml index ee07fb52b..b6972b6ec 100644 --- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml @@ -201,11 +201,8 @@ Publieke sleutel komt niet overeen Nieuwe node meldingen SNR - Signal-to-Noise Ratio, een meeting die wordt gebruikt in de communicatie om het niveau van een gewenst signaal tegenover achtergrondlawaai te kwantificeren. In Meshtastische en andere draadloze systemen geeft een hoger SNR een zuiverder signaal aan dat de betrouwbaarheid en kwaliteit van de gegevensoverdracht kan verbeteren. RSSI - Ontvangen Signal Sterkte Indicator, een meting gebruikt om het stroomniveau te bepalen dat de antenne ontvangt. Een hogere RSSI-waarde geeft een sterkere en stabielere verbinding aan. (Binnenluchtkwaliteit) relatieve schaal IAQ waarde gemeten door Bosch BME680. Waarde tussen 0 en 500. - Node Kaart Positie Beheer Extern beheer @@ -312,6 +309,8 @@ Inkomende negeren Negeer MQTT MQTT Configuratie + Niet verbonden + Verbonden MQTT ingeschakeld Adres Gebruikersnaam @@ -416,4 +415,5 @@ Blauw Groen Verbinding maken + Filter diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml index 2ecd2a425..cd00c43e2 100644 --- a/core/resources/src/commonMain/composeResources/values-no/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml @@ -194,11 +194,8 @@ Direktemeldinger bruker den nye offentlige nøkkelinfrastrukturen for kryptering. Krever firmware versjon 2.5 eller høyere. Varsel om nye noder SNR - Signal-to-Noise Ratio, et mål som brukes i kommunikasjon for å sette nivået av et ønsket signal til bakgrunnstrøynivået. I Meshtastic og andre trådløse systemer tyder et høyere SNR på et klarere signal som kan forbedre påliteligheten og kvaliteten på dataoverføringen. RSSI - \"Received Signal Strength Indicator\", en måling som brukes til å bestemme strømnivået som mottas av antennen. Høyere RSSI verdi indikerer generelt en sterkere og mer stabil forbindelse. (Innendørs luftkvalitet) relativ skala IAQ-verdi målt ved Bosch BME680. Verdi 0–500. - Nodekart Administrasjon Fjernadministrasjon Dårlig @@ -222,6 +219,7 @@ Kopier Varsel, bjellekarakter! Region + Frakoblet Offentlig nøkkel Privat nøkkel Tidsavbrudd @@ -240,4 +238,5 @@ + Filter diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 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 @@ Ciemny Domyślne ustawienie systemowe Wybierz motyw + Standardowy Podaj lokalizację telefonu do sieci Usunąć wiadomość? @@ -268,6 +269,7 @@ Zresetuj NodeDB Dostarczono Błąd + Nieznany błąd Zignoruj Usuń z listy ignorowanych Dodać '%1$s' do listy ignorowanych? @@ -331,12 +333,9 @@ Informacje o użytkowniku Powiadomienia o nowych węzłach SNR: - Współczynnik sygnału do szumu (Signal-to-Noise Ratio) - miara stosowana w komunikacji do określania poziomu pożądanego sygnału w stosunku do poziomu szumu tła. W Meshtastic i innych systemach bezprzewodowych wyższy współczynnik SNR oznacza czystszy sygnał, który może zwiększyć niezawodność i jakość transmisji danych. RSSI: - Received Signal Strength Indicator - miara używana do określenia poziomu mocy odbieranej przez antenę. Wyższa wartość RSSI zazwyczaj oznacza silniejsze i bardziej stabilne połączenie. Jakość powietrza w pomieszczeniach (Indoor Air Quality) - wartość względna w skali IAQ mierzona czujnikiem BME680. Zakres wartości: 0–500. Metryka urządzenia - Ślad na mapie Pozycjonowanie Ostatnia aktualizacja lokalizacji Metryki środowiskowe @@ -497,6 +496,8 @@ Zignoruj MQTT Ok dla MQTT Konfiguracja MQTT + Rozłączono + Połączony Włącz MQTT Adres Nazwa użytkownika @@ -747,4 +748,6 @@ Moduł Włączony Połącz Wykonano + Meshtastic + Filtr diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml index 7e753eefc..ac97b091c 100644 --- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -230,11 +230,8 @@ Chave pública não confere Novas notificações de nó SNR - Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado para o nível de ruído de fundo. Na Meshtastic e outros sistemas sem fios, uma SNR maior indica um sinal mais claro que pode melhorar a confiabilidade e a qualidade da transmissão de dados. RSSI - Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de potência que está sendo recebida pela antena. Um valor maior de RSSI geralmente indica uma conexão mais forte e mais estável. (Qualidade do ar interior) valor relativo da escala IAQ medido pelo Bosch BME680. Intervalo de Valor de 0–500. - Mapa do nó Posição Atualização da última posição Administração @@ -382,6 +379,8 @@ Ventilador do PA desativado Ignorar MQTT Configurações MQTT + Desconectado + Conectado MQTT habilitado Endereço Nome de usuário @@ -665,4 +664,6 @@ Azul Verde Concluído + Meshtastic + Filtro diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml index 0cc07b820..a00bce554 100644 --- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml @@ -219,11 +219,8 @@ Incompatibilidade de chave pública Notificações de novos nodes SNR - Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado com o nível de ruído de fundo. Em Meshtastic e outros sistemas sem fio. Quanto mais alta for a relação sinal-ruído, menor é o efeito do ruído de fundo sobre a deteção ou medição do sinal. RSSI - Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de energia que está a ser recebido pela antena. Um valor mais elevado de RSSI geralmente indica uma conexão mais forte e mais estável. (Qualidade do ar interior) valor relativo da escala IAQ conforme medida por Bosch BME680. Entre 0–500. - Mapa de nodes Posição Administração Administração Remota @@ -366,6 +363,8 @@ Ignorar entrada Ignorar MQTT Configuração MQTT + Desconectado + Ligado MQTT ativo Endereço Utilizador @@ -515,4 +514,6 @@ Azul Verde Ligar + Nome do nó de alternativo + Filtrar diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index 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ție via MQTT via MQTT + Intern după favorite Arată doar nodurile ignorate + Exclude MQTT Nerecunoscut În așteptarea confirmării În coadă pentru trimitere + Livrat la Mesh + Necunoscut Rutare 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 @@ Distribuie Nod nou găsit: %1$s Deconectat - Dispozitiv în sleep mode + Adormirea dispozitivului Adresa IP: Port: Conectat @@ -161,14 +169,22 @@ Conectare Neconectat Nici un dispozitiv selectat + Dispozitiv necunoscut + Nici un dispozitiv de rețea găsit + Niciun dispozitiv USB găsit + USB + Mod demonstrativ Connectat la dispozitivi, dar e în modul de sleep Aplicație prea veche Trebuie 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 serviciului Mulț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$d Acest URL de canal este invalid și nu poate fi folosit - Panou debug + Panou de depanare Date decodate: Export jurnale %1$d (de) jurnale exportate @@ -194,14 +210,28 @@ Ștergeți toate filtrele Adăugare filtru personalizat Presetă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 jurnalele Potrivire oricare | toate Potrivire toate | oricare Aceasta 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ţii Canal + %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 stiluri Status livrare mesaj Mesaje noi mai jos Notificări mesaje directe @@ -227,6 +257,7 @@ Setarea telefonului Alege tema Furnizaț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$s Restartează - Traceroute + Trasare traseu Arată Introducere Mesaj Opțiuni chat rapid @@ -269,7 +300,9 @@ Mesaj direct Resetare NodeDB Livrare confirmată + Dispozitivul dumneavoastră se poate deconecta şi reporni în timp ce setările sunt aplicate. Eroare + Eroare necunoscuta Ignoră 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țios Nu este silențios + Silențios pentru %1$d zile, %2$s ore + Silențios pentru %1$s ore Dezactivați notificările pentru „%1$s”? Activați notificările pentru '%1$s'? Înlocuire @@ -319,6 +354,8 @@ Baterie ChUtil AirUtil + %1$s + %1$s:%2$s Temp Hum Temp sol @@ -339,12 +376,9 @@ Info utilizator Notificări noduri noi SNR - Raportul semnal-zgomot (Signal-to-Noise Ratio), o măsură utilizată în comunicații pentru a cuantifica nivelul unui semnal dorit în raport cu nivelul zgomotului de fond. În Meshtastic și în alte sisteme wireless, un SNR mai mare indică un semnal mai clar, care poate îmbunătăți fiabilitatea și calitatea transmiterii datelor. RSSI - Indicatorul intensității semnalului recepționat (Received Signal Strength Indicator), o măsurătoare utilizată pentru a determina nivelul de putere recepționat de antenă. O valoare RSSI mai mare indică, în general, o conexiune mai puternică și mai stabilă. (Calitatea aerului interior) valoarea IAQ pe o scară relativă, măsurată cu Bosch BME680. Intervalul valorilor: 0–500. Valori dispozitiv - Harta nodurilor Poziție Ultima actualizare a poziției Indicatori de mediu @@ -374,11 +408,21 @@ Durată: %1$s s Ruta trasată spre destinație:\n\n Ruta 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 minut Media de încărcare sistem de cinci minute 24H 1W 2W Maxim + Extindeți graficul + Restrânge graficul Vârstă necunoscută Copiere Caracter clopoțel de alertă! @@ -392,11 +436,17 @@ Canalul 1 Canalul 2 Canalul 3 + Canalul 4 + Canalul 5 + Canalul 6 + Canalul 7 + Canalul 8 Actual Tensiune Sunteț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ărcate Baterie descărcată: %1$s Notificări pentru baterii descărcate (noduri favorite) @@ -527,12 +577,26 @@ LoRa Opțiuni Avansate + Utilizare presetare Presetări Lățime bandă + Factor de răspândire + Rata de codificare Regiune + 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 dezactivat Ignoră MQTT Acceptă MQTT Configurare MQTT + Deconectat + Conectat MQTT activat Adresă 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-Fi Activat + WiFi activat + Numele rețelei + PSK + Opţiuni Ethernet + Ethernet activat + Server NTP + server rsyslog + Mod IPv4 + IP + Poartă de acces + Subred + DNS Configurație Paxcounter Paxcounter activat + Mesaj de stare: + Configurare mesaj prestabilit + Șirul de stare real Pragul 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 + TX Expirat + 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 Nodului Nume lung Nume scurt Model 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 gaz Distanță + Lux Vânt + Viteza vântului + Viteza rafale + Vânt critic + Directie vânt + Ploaie (1h) + Ploaie (24h) Greutate Radiaț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? + Netransmisibil Nemonitorizată sau infrastructură - + Cheie publică schimbată + Importa + Solicitare + Se solicită %1$s de la %2$s + Informații utilizator + Solicită telemetrie Valori dispozitiv Indicatori de mediu + Calitatea aerului, valoare Valori 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 Favorite Arată 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ă jos Meshtastic + 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ția Mesaj + 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 conectate Rata 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 dispozitiv Activează/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 Ore 24 Ore 48 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$s Stabil + 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... + Înapoi Nesetat + 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. Toate Bluetooth + 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 + Mov Roșu + Maro + Violet + Albastru închis Albastru + Azuriu + Albastru-verzui Verde + 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 @@ Батарея ChUtil AirUtil - %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 @@ Макс Мин - Сред Развернуть диаграмму Свернуть диаграмму Неизвестный возраст @@ -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ľúča Notifikácie nových uzlov SNR - Pomer signálu od šumu (SNR), miera používaná v komunikácii na kvantifikáciu úrovne požadovaného signálu k úrovni hluku pozadia. V Meshtastic a iných bezdrôtových systémoch znamená vyšší SNR jasnejší signál, ktorý môže zvýšiť spoľahlivosť a kvalitu prenosu údajov. RSSI - Indikátor sily prijímaného signálu (RSSI), meranie používané na určenie úrovne výkonu prijatého skrz anténu. Vyššia hodnota RSSI vo všeobecnosti znamená silnejšie a stabilnejšie pripojenie. (Kvalita vzduchu v interiéri) relatívna hodnota IAQ meraná prístrojom Bosch BME680. Rozsah hodnôt 0–500. - Mapa uzlov Pozícia Administrácia Administrácia na diaľku @@ -362,6 +359,8 @@ LoRa Šírka pásma Región + Odpojené + Pripojený Adresa Používateľské meno Heslo @@ -427,4 +426,6 @@ Červená Modrá Zelená + Meshtastic + Filter diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml index 8025c4751..bff8e6150 100644 --- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml @@ -196,11 +196,8 @@ Neujemanje javnega ključa Obvestila novih vozlišč SNR - Razmerje med signalom in šumom je merilo, ki se uporablja v komunikacijah za količinsko opredelitev ravni želenega signala glede na raven hrupa v ozadju. V Meshtastic in drugih brezžičnih sistemih višji SNR pomeni jasnejši signal, ki lahko poveča zanesljivost in kakovost prenosa podatkov. RSSI - Indikator moči sprejetega signala je meritev, ki se uporablja za določanje ravni moči, ki jo sprejema antena. Višja vrednost RSSI na splošno pomeni močnejšo in stabilnejšo povezavo. (Kakovost zraka v zaprtih prostorih) relativna vrednost IAQ na lestvici, izmerjena z Bosch BME680. Razpon vrednosti 0–500. - Zemljevid vozlišč Administracija Administracija na daljavo Slab @@ -226,6 +223,7 @@ Kopiraj Znak opozorilnega zvonca! Regija + Prekinjeno Javni ključ Zasebni ključ Časovna omejitev @@ -244,4 +242,5 @@ + Filter diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml index e70391f4d..edfac59b0 100644 --- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml @@ -186,10 +186,7 @@ Kriptimi me Çelës Publik Përputhje e Gabuar e Çelësit Publik Njoftimet për nyje të reja - Raporti i Sinjalit në Zhurmë, një masë e përdorur në komunikime për të kuantifikuar nivelin e një sinjali të dëshiruar ndaj nivelit të zhurmës në background. Në Meshtastic dhe sisteme të tjera pa tel, një SNR më i lartë tregon një sinjal më të pastër që mund të rrisë besueshmërinë dhe cilësinë e transmetimit të të dhënave. - Indikatori i Fuqisë së Sinjalit të Marrë, një matje e përdorur për të përcaktuar nivelin e energjisë që po merret nga antena. Një vlerë më e lartë RSSI zakonisht tregon një lidhje më të fortë dhe më të qëndrueshme. (Cilësia e Ajrit të Brendshëm) shkalla relative e vlerës IAQ siç matet nga Bosch BME680. Intervali i Vlerave 0–500. - Harta e Nyjës Administratë Administratë e Largët I Keq @@ -202,6 +199,7 @@ Hops drejt %1$d Hops prapa %2$d 訊息 Rajon + I shkëputur Koha e skaduar Distanca @@ -218,4 +216,5 @@ + Filtrimi diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index 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 Позитиван за MQTT MQTT подешавања + 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 Позитиван за MQTT MQTT подешавања + Раскачено + Блутут повезан Адреса Корисничко име Лозинка @@ -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änd Inväntar kvittens Kvittens köad + Levererad till nät Okänd Kvitterad Ingen rutt @@ -272,6 +273,7 @@ Nollställ NodeDB Sändning bekräftad Fel + Okänt fel Ignorera Ta bort från ignorerade Lägg till '%1$s' på ignorera-listan? Din radioenhet kommer att starta om efter denna ändring. @@ -338,12 +340,9 @@ Användarinfo Ny nod avisering SNR - Signal-to-Noise Ratio, är ett mått som används inom kommunikation för att kvantifiera nivån av en önskad signal mot nivån av bakgrundsbrus. I Meshtastic och andra trådlösa system indikerar en högre SNR en tydligare signal som kan förbättra tillförlitligheten och kvaliteten på dataöverföringen. RSSI - Received Signal Strength Indicator, ett mått som används för att avgöra effektnivån som togs emot av antennen. Ett högre RSSI-värde indikerar generellt en starkare och stabilare anslutning. (Indoor Air Quality) relativ skala IAQ värdet mätt med Bosch BME600. Värdeintervall 0-500. Enhetens mätvärden - Nod karta Plats Senaste positionsuppdatering Miljövärden @@ -372,6 +371,7 @@ Varaktighet: %1$s s Rutt spårad mot destination:\n\n Rutten spårad tillbaka till oss:\n\n + Inget svar 1h 24T 1V @@ -528,6 +528,10 @@ Ignorera MQTT Ok till MQTT MQTT-konfiguration + Frånkopplad + Ansluten + Testa anslutningen + Anslutningen misslyckades MQTT är aktiverat Adress Användarnamn @@ -944,4 +948,7 @@ Modul aktiverad Anslut Klart + Meshtastic + Filter + Välj enhet diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml index cbd1be2ae..75a9e3a5d 100644 --- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml @@ -213,11 +213,8 @@ Genel Anahtar Uyuşmazlığı Yeni düğüm bildirimleri SNR - Sinyal-Gürültü Oranı, iletişimde istenen bir sinyalin seviyesini arka plan gürültüsü seviyesine mukayese ölçmek için kullanılan bir ölçüdür. Meshtastic ve diğer kablosuz sistemlerde, daha yüksek bir SNR, veri iletiminin güvenilirliğini ve kalitesini artırabilecek daha net bir sinyale işaret eder. RSSI - Alınan Sinyal Gücü Göstergesi, anten tarafından alınan güç seviyesini belirlemek için kullanılan bir ölçüdür. Daha yüksek bir RSSI değeri genellikle daha güçlü ve daha istikrarlı bir bağlantıya işaret eder. (İç Hava Kalitesi) Bosch BME680 tarafından ölçülen bağıl ölçekli IAQ değeri. Değer Aralığı 0–500. - Düğüm Haritası Konum Yönetim Uzaktan Yönetim @@ -366,6 +363,8 @@ PA fanı devre dışı MQTT'yi Yoksay MQTT Yapılandırması + Bağlantı kesildi + Bağlandı MQTT etkin Adres Kullanıcı adı @@ -546,4 +545,6 @@ Mavi Yeşil Bağlan + Meshtastic + Filtre diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index 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 @@ Сповіщення про нові вузли SNR RSSI - Показник рівня потужності сигналу — вимірювання, що використовується для визначення рівня потужності, що приймається антеною. Вище значення 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 @@ 电池 ChUtil AirUtil - %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 使用MQTT MQTT设置 + 已断开连接 + 已连接 + 连接测试 启用MQTT 地址 用户名 @@ -1114,4 +1114,7 @@ 备注 连接 完成 + Meshtastic + 搜索节点 + 选择设备 diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml index 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 允許轉發至 MQTT MQTT配置 + 已停用 + 已中斷連線 + 已斷線 — %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 defaults Apply Theme + Contrast Light Dark System default Choose theme + Contrast level + Standard + Medium + High Provide phone location to mesh Compact encoding for Cyrillic @@ -327,6 +332,7 @@ Delivery confirmed Your device may disconnect and reboot while settings are applied. Error + Unknown error Ignore Remove from ignored Add '%1$s' to ignore list? @@ -378,9 +384,9 @@ Battery ChUtil AirUtil - %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 Temp Hum @@ -402,12 +408,9 @@ User Info New node notifications SNR - Signal-to-Noise Ratio, a measure used in communications to quantify the level of a desired signal to the level of background noise. In Meshtastic and other wireless systems, a higher SNR indicates a clearer signal that can enhance the reliability and quality of data transmission. RSSI - Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection. (Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500. Device Metrics - Node Map Position Last position update Environment Metrics @@ -454,7 +457,6 @@ 1M Max Min - Avg Expand chart Collapse chart Unknown Age @@ -606,6 +608,9 @@ Output duration (milliseconds) Nag timeout (seconds) Ringtone + Imported ringtone + File is empty + Error importing: %1$s Play Use I2S as buzzer LoRa @@ -629,6 +634,23 @@ Ignore MQTT Ok to MQTT MQTT Config + Inactive + Disconnected + Disconnected — %1$s + Connecting… + Connected + Reconnecting… + Reconnecting (attempt %1$d) — %2$s + Test connection + Probing broker… + Reachable. Broker accepted credentials. + Reachable (%1$s) + Broker rejected: %1$s + Host not found + Cannot reach broker (TCP) + TLS handshake failed + Timed out after %1$d ms + Connection failed MQTT enabled Address Username @@ -744,6 +766,7 @@ Rain (24h) Weight Radiation + 1-Wire Temp Indoor Air Quality (IAQ) URL @@ -1251,4 +1274,22 @@ Enter or select a network WiFi configured successfully! Failed to apply WiFi configuration + Meshtastic Desktop + Show Meshtastic + Quit + Meshtastic + Export TAK Data Package + mPWRD-OS + Clear time zone + Filter + Remove filter + Show air quality legend + Show message status + Send reply + Copy message + Select message + Delete message + React with emoji + Select device + Select network diff --git a/core/service/src/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>>(emptyMap()) + private val requestIds = mutableMapOf() + + init { + registerResetAction { requestIds.clear() } + } + + override fun getSnapshotPositions(logUuid: String): Flow> = + snapshots.map { it[logUuid].orEmpty() } + + override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) { + requestIds[logUuid] = requestId + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Directly seeds the snapshot for a log (bypasses request-id tracking). */ + fun seedSnapshot(logUuid: String, positions: Map) { + snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } + } + + /** Returns the last request-id recorded for [logUuid], or `null` if none. */ + fun lastRequestId(logUuid: String): Int? = requestIds[logUuid] +} diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt new file mode 100644 index 000000000..f9a63c712 --- /dev/null +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import app.cash.turbine.test +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.database.entity.QuickChatAction +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RepositoryFakesTest { + + @Test + fun `FakeDeviceHardwareRepository returns seeded hardware and records calls`() = runTest { + val repo = FakeDeviceHardwareRepository() + val hw = DeviceHardware(hwModel = 42, hwModelSlug = "TEST", platformioTarget = "tlora") + repo.setHardware(hwModel = 42, target = "tlora", device = hw) + + val hit = repo.getDeviceHardwareByModel(hwModel = 42, target = "tlora", forceRefresh = false) + val miss = repo.getDeviceHardwareByModel(hwModel = 99) + + assertEquals(hw, hit.getOrNull()) + assertNull(miss.getOrNull()) + assertEquals(2, repo.recordedCalls.size) + assertEquals(Triple(42, "tlora", false), repo.recordedCalls.first()) + } + + @Test + fun `FakeFirmwareReleaseRepository emits stable and alpha releases`() = runTest { + val repo = FakeFirmwareReleaseRepository() + val stable = FirmwareRelease(id = "1.0", title = "1.0", pageUrl = "", zipUrl = "") + val alpha = FirmwareRelease(id = "1.1-a", title = "1.1-a", pageUrl = "", zipUrl = "") + + repo.setStableRelease(stable) + repo.setAlphaRelease(alpha) + + assertEquals(stable, repo.stableRelease.first()) + assertEquals(alpha, repo.alphaRelease.first()) + + repo.invalidateCache() + repo.invalidateCache() + assertEquals(2, repo.invalidateCacheCalls) + } + + @Test + fun `FakeQuickChatActionRepository upsert delete and reorder`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "hi", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "bye", position = 1) + + repo.upsert(a) + repo.upsert(b) + assertEquals(listOf(a, b), repo.getAllActions().first()) + + repo.setItemPosition(uuid = 1L, newPos = 5) + assertEquals(listOf(2L, 1L), repo.getAllActions().first().map { it.uuid }) + + repo.delete(b) + assertEquals(1, repo.currentActions.size) + + repo.deleteAll() + assertTrue(repo.currentActions.isEmpty()) + } + + @Test + fun `FakeQuickChatActionRepository delete compacts positions`() = runTest { + val repo = FakeQuickChatActionRepository() + val a = QuickChatAction(uuid = 1L, name = "A", message = "", position = 0) + val b = QuickChatAction(uuid = 2L, name = "B", message = "", position = 1) + val c = QuickChatAction(uuid = 3L, name = "C", message = "", position = 2) + repo.upsert(a) + repo.upsert(b) + repo.upsert(c) + + repo.delete(b) + + // Matches real DAO's decrementPositionsAfter: positions must stay contiguous. + assertEquals(listOf(1L to 0, 3L to 1), repo.currentActions.map { it.uuid to it.position }) + } + + @Test + fun `FakeTracerouteSnapshotRepository roundtrips positions keyed by log uuid`() = runTest { + val repo = FakeTracerouteSnapshotRepository() + val positions = mapOf(1 to Position(latitude_i = 10), 2 to Position(latitude_i = 20)) + repo.upsertSnapshotPositions(logUuid = "log-1", requestId = 99, positions = positions) + + repo.getSnapshotPositions("log-1").test { assertEquals(positions, awaitItem()) } + assertEquals(99, repo.lastRequestId("log-1")) + assertNull(repo.lastRequestId("other")) + } + + @Test + fun `FakeRadioConfigRepository tracks channel set and module config`() = runTest { + val repo = FakeRadioConfigRepository() + val a = ChannelSettings(name = "A") + val b = ChannelSettings(name = "B") + + repo.replaceAllSettings(listOf(a, b)) + assertEquals(listOf(a, b), repo.currentChannelSet.settings) + + repo.updateChannelSettings(Channel(index = 1, settings = ChannelSettings(name = "B2"))) + assertEquals("B2", repo.currentChannelSet.settings[1].name) + + repo.clearChannelSet() + assertTrue(repo.currentChannelSet.settings.isEmpty()) + } +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 99221edf1..44b483c91 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -55,11 +55,11 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite) - implementation(libs.jetbrains.navigationevent.compose) implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.compose.material3.adaptive.navigation3) implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) implementation(libs.jetbrains.lifecycle.viewmodel.compose) + implementation(libs.jetbrains.lifecycle.runtime.compose) } val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } } diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index f8b0586f4..aa47539bb 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -27,17 +27,18 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import org.meshtastic.core.common.util.nowMillis @Composable actual fun rememberTimeTickWithLifecycle(): Long { val context = LocalContext.current - var value by remember { mutableLongStateOf(System.currentTimeMillis()) } + var value by remember { mutableLongStateOf(nowMillis) } DisposableEffect(context) { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - value = System.currentTimeMillis() + value = nowMillis } } 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 559169139..5365ab95e 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -20,24 +20,29 @@ package org.meshtastic.core.ui.util import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers +import com.eygraber.uri.toAndroidUri +import com.eygraber.uri.toKmpUri 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.MeshtasticUri +import org.meshtastic.core.common.util.ioDispatcher import java.net.URLEncoder @Composable @@ -102,16 +107,14 @@ actual fun rememberOpenUrl(): (url: String) -> Unit { @Composable @Suppress("Wrapping") actual fun rememberSaveFileLauncher( - onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit, + onUriReceived: (org.meshtastic.core.common.util.CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit { val launcher = androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(), ) { result -> if (result.resultCode == android.app.Activity.RESULT_OK) { - result.data?.data?.let { uri -> - onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) }) - } + result.data?.data?.let { uri -> onUriReceived(uri.toKmpUri()) } } } @@ -132,7 +135,7 @@ actual fun rememberSaveFileLauncher( actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit { val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - onUriReceived(uri?.let { CommonUri(it) }) + onUriReceived(uri?.let { it.toKmpUri() }) } return remember(launcher) { { mimeType -> launcher.launch(mimeType) } } } @@ -143,10 +146,10 @@ 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.parse(uri.toString()) + val androidUri = uri.toAndroidUri() context.contentResolver.openInputStream(androidUri)?.use { stream -> stream.bufferedReader().use { reader -> val buffer = CharArray(maxChars) @@ -216,3 +219,67 @@ actual fun rememberOpenLocationSettings(): () -> Unit { } return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } } } + +@Composable +actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + // On pre-Android 12, BLE scanning is gated by location permission, not Bluetooth. + return remember { { onGranted() } } + } + val currentOnGranted = rememberUpdatedState(onGranted) + val currentOnDenied = rememberUpdatedState(onDenied) + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.values.all { it }) currentOnGranted.value() else currentOnDenied.value() + } + return remember(launcher) { + { + launcher.launch( + arrayOf(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT), + ) + } + } +} + +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + // Pre-Android 13, no runtime notification permission required. + return remember { { onGranted() } } + } + val currentOnGranted = rememberUpdatedState(onGranted) + val currentOnDenied = rememberUpdatedState(onDenied) + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) currentOnGranted.value() else currentOnDenied.value() + } + return remember(launcher) { { launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } } +} + +@Composable +actual fun isLocationPermissionGranted(): Boolean { + val context = LocalContext.current + return rememberOnResumeState { + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_FINE_LOCATION, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } +} + +@Composable +actual fun isGpsDisabled(): Boolean { + val context = LocalContext.current + return rememberOnResumeState { context.gpsDisabled() } +} + +/** + * Remembers a boolean state that is re-evaluated on each [Lifecycle.Event.ON_RESUME], ensuring the value stays fresh + * when the user returns from a permission dialog or system settings screen. + */ +@Composable +private fun rememberOnResumeState(check: () -> Boolean): Boolean { + val state = remember { mutableStateOf(check()) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { state.value = check() } + return state.value +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt index 7330c1aa6..125e1e117 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt @@ -38,6 +38,7 @@ fun ClickableTextField( onClick: () -> Unit, modifier: Modifier = Modifier, isError: Boolean = false, + trailingIconContentDescription: String? = null, ) { val source = remember { MutableInteractionSource() } val isPressed by source.collectIsPressedAsState() @@ -49,7 +50,7 @@ fun ClickableTextField( enabled = enabled, readOnly = true, label = { Text(stringResource(label)) }, - trailingIcon = { Icon(trailingIcon, null) }, + trailingIcon = { Icon(trailingIcon, trailingIconContentDescription) }, isError = isError, interactionSource = source, modifier = modifier, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt index 9d41d5f5a..22c6bfaf5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt @@ -62,12 +62,13 @@ fun > DropDownPreference( enumEntriesOf(selectedItem).filter { it.name != "UNRECOGNIZED" && !it.isDeprecatedEnumEntry() } } - val items = enumConstants.map { - val label = itemLabel?.invoke(it) ?: it.name - val icon = itemIcon?.invoke(it) - val color = itemColor?.invoke(it) - DropDownItem(it, label, icon, color) - } + val items = + enumConstants.map { + val label = itemLabel?.invoke(it) ?: it.name + val icon = itemIcon?.invoke(it) + val color = itemColor?.invoke(it) + DropDownItem(it, label, icon, color) + } DropDownPreference( title = title, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt index 2dce97aa5..10b83ce41 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt @@ -23,7 +23,7 @@ import androidx.compose.material3.IconToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction @@ -49,7 +49,7 @@ fun EditPasswordPreference( onValueChanged: (String) -> Unit, modifier: Modifier = Modifier, ) { - var isPasswordVisible by remember { mutableStateOf(false) } + var isPasswordVisible by rememberSaveable { mutableStateOf(false) } EditTextPreference( title = title, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt index c461a065f..d8df4101b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -90,10 +91,10 @@ fun MeshtasticImportFAB( ) { sharedContact?.let { importDialog(it, onDismissSharedContact) } - var expanded by remember { mutableStateOf(false) } - var showUrlDialog by remember { mutableStateOf(false) } - var isNfcScanning by remember { mutableStateOf(false) } - var showNfcDisabledDialog by remember { mutableStateOf(false) } + var expanded by rememberSaveable { mutableStateOf(false) } + var showUrlDialog by rememberSaveable { mutableStateOf(false) } + var isNfcScanning by rememberSaveable { mutableStateOf(false) } + var showNfcDisabledDialog by rememberSaveable { mutableStateOf(false) } val openNfcSettings = rememberOpenNfcSettings() val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt index b84c11e13..2fa66b468 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -58,6 +59,7 @@ import org.meshtastic.core.resources.preview_gauge import org.meshtastic.core.resources.preview_gradient import org.meshtastic.core.resources.preview_pill import org.meshtastic.core.resources.preview_text +import org.meshtastic.core.resources.show_iaq_legend import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.ThumbUp import org.meshtastic.core.ui.icon.Warning @@ -120,13 +122,18 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil Column { when (displayMode) { IaqDisplayMode.Pill -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Box( modifier = Modifier.clip(RoundedCornerShape(10.dp)) .background(iaqEnum.color) .width(125.dp) .height(30.dp) - .clickable { isLegendOpen = true }, + .clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) { Row( modifier = Modifier.padding(4.dp).align(Alignment.CenterStart), @@ -144,7 +151,15 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Dot -> { - Column(modifier = Modifier.clickable { isLegendOpen = true }) { + val legendLabel = stringResource(Res.string.show_iaq_legend) + Column( + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), + ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(text = "$iaq") Spacer(modifier = Modifier.width(4.dp)) @@ -154,17 +169,30 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Text -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Text( text = getIaqDescriptionWithRange(iaqEnum), fontSize = 12.sp, - modifier = Modifier.clickable { isLegendOpen = true }, + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) } IaqDisplayMode.Gauge -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) CircularProgressIndicator( progress = { iaq / 500f }, - modifier = Modifier.size(60.dp).clickable { isLegendOpen = true }, + modifier = + Modifier.size(60.dp) + .clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), strokeWidth = 8.dp, color = iaqEnum.color, ) @@ -172,9 +200,15 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Gradient -> { + val legendLabel = stringResource(Res.string.show_iaq_legend) Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.clickable { isLegendOpen = true }, + modifier = + Modifier.clickable( + onClickLabel = legendLabel, + role = Role.Button, + onClick = { isLegendOpen = true }, + ), ) { LinearProgressIndicator( progress = { iaq / 500f }, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt index 216ec2108..753468600 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt @@ -41,7 +41,7 @@ import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bad import org.meshtastic.core.resources.fair @@ -154,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) { Text( modifier = modifier, - text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr), + text = "${stringResource(Res.string.snr)} ${MetricFormatter.snr(snr, decimalPlaces = 2)}", color = color, style = MaterialTheme.typography.labelSmall, ) @@ -172,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) { } Text( modifier = modifier, - text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi), + text = "${stringResource(Res.string.rssi)} ${MetricFormatter.rssi(rssi)}", color = color, style = MaterialTheme.typography.labelSmall, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt index 7e8bd9b6a..1445bdedf 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.icon.BatteryEmpty @@ -49,7 +49,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusRed -private const val FORMAT = "%d%%" private const val SIZE_ICON = 16 @Suppress("MagicNumber", "LongMethod") @@ -60,7 +59,7 @@ fun MaterialBatteryInfo( voltage: Float? = null, contentColor: Color = MaterialTheme.colorScheme.onSurface, ) { - val levelString = formatString(FORMAT, level) + val levelString = level?.let { MetricFormatter.percent(it) } ?: stringResource(Res.string.unknown) Row( modifier = modifier, @@ -130,7 +129,7 @@ fun MaterialBatteryInfo( ?.takeIf { it > 0 } ?.let { Text( - text = formatString("%.2fV", it), + text = MetricFormatter.voltage(it), color = contentColor.copy(alpha = 0.8f), style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp), ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt index afa82460d..f9f839ea5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -80,7 +81,13 @@ fun RegularPreference( MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) } - Column(modifier = modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(all = 16.dp)) { + Column( + modifier = + modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick, role = Role.Button) + .padding(all = 16.dp), + ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { FlowRow(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt index 5a6c58c23..f817ec4e4 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.signal_quality @@ -65,7 +65,10 @@ fun SignalInfo( tint = signalColor, ) Text( - text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)), + text = + "${MetricFormatter.snr( + node.snr, + )} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt index 815f9beb7..a0b87ca6a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kermit.Logger import kotlinx.coroutines.launch import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.okay @@ -89,7 +90,14 @@ fun TracerouteAlertHandler( uiViewModel.clearTracerouteResponse() // Post the error alert after the current alert is dismissed to avoid // the wrapping dismissAlert() in AlertManager immediately clearing it. - scope.launch { uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) } + @Suppress("TooGenericExceptionCaught") + scope.launch { + try { + uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) + } catch (e: Exception) { + Logger.e(e) { "[TracerouteAlertHandler] Failed to show error alert" } + } + } } }, dismissTextRes = Res.string.okay, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt index b0e01011e..4a710b0b3 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt @@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -117,8 +118,8 @@ fun EmojiPickerDialog( onConfirm: (String) -> Unit, ) { val viewModel: EmojiPickerViewModel = koinViewModel() - var searchQuery by remember { mutableStateOf("") } - var selectedCategoryIndex by remember { mutableStateOf(0) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) } val recentEmojis by remember(viewModel.customEmojiFrequency) { derivedStateOf { parseRecents(viewModel.customEmojiFrequency) } } @@ -427,7 +428,7 @@ private fun SectionHeader(title: String) { @OptIn(ExperimentalFoundationApi::class) @Composable private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (String) -> Unit) { - var showSkinTonePopup by remember { mutableStateOf(false) } + var showSkinTonePopup by rememberSaveable { mutableStateOf(false) } Box { Box( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt index 66060116f..6bf669ab6 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt @@ -25,6 +25,7 @@ import org.meshtastic.core.resources.ic_fingerprint import org.meshtastic.core.resources.ic_fork_left import org.meshtastic.core.resources.ic_home import org.meshtastic.core.resources.ic_icecream +import org.meshtastic.core.resources.ic_memory import org.meshtastic.core.resources.ic_military_tech import org.meshtastic.core.resources.ic_mountain_flag import org.meshtastic.core.resources.ic_my_location @@ -75,4 +76,4 @@ val MeshtasticIcons.DeviceNumbers: ImageVector val MeshtasticIcons.Android: ImageVector @Composable get() = vectorResource(Res.drawable.ic_android) val MeshtasticIcons.HardwareModel: ImageVector - @Composable get() = vectorResource(Res.drawable.ic_router) + @Composable get() = vectorResource(Res.drawable.ic_memory) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index d5f4e31ec..7e5271148 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -89,7 +90,7 @@ fun ScannedQrCodeDialog( onDismiss: () -> Unit, onConfirm: (ChannelSet) -> Unit, ) { - var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) } + var shouldReplace by rememberSaveable { mutableStateOf(incoming.lora_config != null) } val channelSet = remember(shouldReplace, channels, incoming) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index 2c10206aa..db23f1d77 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -17,12 +17,11 @@ package org.meshtastic.core.ui.qr import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -40,7 +39,7 @@ class ScannedQrCodeViewModel( private val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -51,11 +50,11 @@ class ScannedQrCodeViewModel( } private fun setChannel(channel: Channel) { - viewModelScope.launch { radioController.setLocalChannel(channel) } + safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) private fun setConfig(config: Config) { - viewModelScope.launch { radioController.setLocalConfig(config) } + safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt new file mode 100644 index 000000000..cd68cd12c --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt @@ -0,0 +1,44 @@ +/* + * 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.ui.theme + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Application-wide contrast level for accessibility. + * + * [STANDARD] keeps the default Material 3 color scheme. [MEDIUM] uses Material 3 medium-contrast color tokens and + * increases message bubble opacity. [HIGH] uses Material 3 high-contrast color tokens, forces `onSurface` text in + * message bubbles, and replaces translucent node-color fills with opaque theme surfaces plus accent borders. + */ +enum class ContrastLevel(val value: Int) { + STANDARD(0), + MEDIUM(1), + HIGH(2), + ; + + companion object { + fun fromValue(value: Int): ContrastLevel = entries.firstOrNull { it.value == value } ?: STANDARD + } +} + +/** + * Composition local providing the current [ContrastLevel]. + * + * Read by components that need to adapt their rendering for accessibility (e.g. message bubbles, signal indicators). + */ +val LocalContrastLevel = staticCompositionLocalOf { ContrastLevel.STANDARD } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt index 240c01503..d2047b603 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt @@ -60,6 +60,10 @@ object GraphColors { val Lime = Color(0xFFCDDC39) val Indigo = Color(0xFF3F51B5) val DeepOrange = Color(0xFFFF5722) + val Magenta = Color(0xFFE040FB) + val SkyBlue = Color(0xFF03A9F4) + val Chartreuse = Color(0xFF76FF03) + val Coral = Color(0xFFFF6E40) } object StatusColors { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt index eb40222af..07c6ab3ad 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("UnusedPrivateProperty") +@file:Suppress("MatchingDeclarationName") package org.meshtastic.core.ui.theme @@ -25,6 +25,7 @@ import androidx.compose.material3.MotionScheme.Companion.expressive import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color @@ -272,19 +273,33 @@ val unspecified_scheme = ColorFamily(Color.Unspecified, Color.Unspecified, Color fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + contrastLevel: ContrastLevel = ContrastLevel.STANDARD, content: @Composable() () -> Unit, ) { - val dynamicScheme = if (dynamicColor) dynamicColorScheme(darkTheme) else null - val colorScheme = dynamicScheme ?: if (darkTheme) darkScheme else lightScheme + val dynamicScheme = + if (dynamicColor && contrastLevel == ContrastLevel.STANDARD) { + dynamicColorScheme(darkTheme) + } else { + null + } + val colorScheme = + dynamicScheme + ?: when (contrastLevel) { + ContrastLevel.MEDIUM -> if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme + ContrastLevel.HIGH -> if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme + else -> if (darkTheme) darkScheme else lightScheme + } - MaterialExpressiveTheme( - colorScheme = colorScheme, - typography = AppTypography, - motionScheme = expressive(), - content = content, - ) + CompositionLocalProvider(LocalContrastLevel provides contrastLevel) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = AppTypography, + motionScheme = expressive(), + content = content, + ) + } } const val MODE_DYNAMIC = 6969420 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index d5910168b..9d3169c1a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -21,7 +21,6 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri /** Returns a function to open the platform's NFC settings. */ @Composable expect fun rememberOpenNfcSettings(): () -> Unit @@ -41,7 +40,7 @@ import org.meshtastic.core.common.util.MeshtasticUri /** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */ @Composable expect fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit /** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */ @@ -64,3 +63,21 @@ expect fun rememberSaveFileLauncher( /** Returns a launcher to open the platform's location settings. */ @Composable expect fun rememberOpenLocationSettings(): () -> Unit + +/** Returns a launcher to request Bluetooth scan + connect permissions. No-op on platforms without runtime BLE perms. */ +@Composable expect fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit + +/** Returns a launcher to request the POST_NOTIFICATIONS permission. No-op on platforms that don't require it. */ +@Composable +expect fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit + +/** + * Returns whether location permissions are currently granted. Always `true` on platforms without runtime permissions. + */ +@Composable expect fun isLocationPermissionGranted(): Boolean + +/** + * Returns whether GPS/location services are currently disabled at the system level. Always `false` on platforms where + * this concept doesn't apply. + */ +@Composable expect fun isGpsDisabled(): Boolean diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index b1c4cebf2..edfda074c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -36,7 +36,6 @@ import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.MyNodeInfo @@ -99,18 +98,16 @@ class UIViewModel( * 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via * [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations. */ - fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) { - val commonUri = CommonUri.parse(uri.uriString) - + fun handleDeepLink(uri: CommonUri, onInvalid: () -> Unit = {}) { // Try navigation routing first - val navKeys = DeepLinkRouter.route(commonUri) + val navKeys = DeepLinkRouter.route(uri) if (navKeys != null) { _navigationDeepLink.tryEmit(navKeys) return } // Fallback to channel/contact importing - commonUri.dispatchMeshtasticUri( + uri.dispatchMeshtasticUri( onContact = { setSharedContactRequested(it) }, onChannel = { setRequestChannelSet(it) }, onInvalid = onInvalid, @@ -118,6 +115,7 @@ class UIViewModel( } val theme: StateFlow = uiPrefs.theme + val contrastLevel: StateFlow = uiPrefs.contrastLevel val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt index 2201d70bd..905d50c2b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt @@ -14,16 +14,30 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon") +@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon", "TooGenericExceptionCaught") package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.UiText +import org.meshtastic.core.resources.unknown_error +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -40,3 +54,82 @@ fun Flow.stateInWhileSubscribed(initialValue: T, stopTimeout: Duration = started = SharingStarted.WhileSubscribed(stopTimeoutMillis = stopTimeout.inWholeMilliseconds), initialValue = initialValue, ) + +// --------------------------------------------------------------------------- +// UiState: shared Loading / Content / Error wrapper +// --------------------------------------------------------------------------- + +/** + * Lightweight tri-state wrapper for UI data. Prefer this over bare nullable initial values when the UI needs to + * distinguish "still loading" from "genuinely empty." + */ +sealed interface UiState { + /** Data has not yet arrived. */ + data object Loading : UiState + + /** Data is available. */ + data class Content(val data: T) : UiState + + /** An error occurred while loading. */ + data class Error(val message: UiText) : UiState +} + +/** Returns the [Content] data, or `null` if this state is [Loading] or [Error]. */ +fun UiState.dataOrNull(): T? = (this as? UiState.Content)?.data + +/** + * Wraps this [Flow] into a `StateFlow>`, emitting [UiState.Loading] until the first value, then + * [UiState.Content] for each emission. Upstream errors are caught and mapped to [UiState.Error]. + */ +context(viewModel: ViewModel) +fun Flow.asUiState(stopTimeout: Duration = 5.seconds): StateFlow> = + this.map> { UiState.Content(it) } + .onStart { emit(UiState.Loading) } + .catch { e -> + val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error) + emit(UiState.Error(message)) + } + .stateInWhileSubscribed(initialValue = UiState.Loading, stopTimeout = stopTimeout) + +// --------------------------------------------------------------------------- +// safeLaunch: CancellationException-safe coroutine launcher with error routing +// --------------------------------------------------------------------------- + +/** + * Launches a coroutine in [viewModelScope] that catches all exceptions except [CancellationException]. Non-cancellation + * errors are logged and emitted to [errorEvents] (if provided) for one-shot UI consumption (e.g. snackbar / toast). + * + * @param context optional [CoroutineContext] element (typically a dispatcher) merged into the launch. Defaults to + * [EmptyCoroutineContext], inheriting [viewModelScope]'s dispatcher. + * + * ``` + * // In a ViewModel: + * safeLaunch(errorEvents = _errors) { + * repository.saveData(data) + * } + * ``` + */ +context(viewModel: ViewModel) +fun safeLaunch( + context: CoroutineContext = EmptyCoroutineContext, + errorEvents: MutableSharedFlow? = null, + tag: String? = null, + block: suspend CoroutineScope.() -> Unit, +): Job = viewModel.viewModelScope.launch(context) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val label = tag ?: "safeLaunch" + Logger.e(e) { "[$label] Unhandled exception" } + val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error) + errorEvents?.tryEmit(message) + } +} + +/** + * Creates and returns a [MutableSharedFlow] intended for one-shot error events. Expose as `SharedFlow` via + * [asSharedFlow] in the ViewModel, and collect in the UI to show snackbars or toasts. + */ +fun errorEventFlow(): MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt index ab0f1a80f..7a442980f 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt @@ -19,7 +19,7 @@ package org.meshtastic.core.ui.component import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt index 650671de2..8380aabcb 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.proto.SharedContact diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt index 7d2e1d1a4..2090736b1 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import kotlin.test.Test import kotlin.test.assertTrue diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index 590bd1fe9..ebe791f8e 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLinkStyles import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.MeshtasticUri actual fun createClipEntry(text: String, label: String): ClipEntry = throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub") @@ -41,7 +40,7 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A @Composable actual fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> } @Composable @@ -57,4 +56,13 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT @Composable actual fun rememberOpenLocationSettings(): () -> Unit = {} +@Composable actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {} + +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {} + +@Composable actual fun isLocationPermissionGranted(): Boolean = true + +@Composable actual fun isGpsDisabled(): Boolean = false + @Composable actual fun SetScreenBrightness(brightness: Float) {} diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index 22f84b217..165262170 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.ui.component import androidx.compose.runtime.Composable +import org.meshtastic.core.common.util.nowMillis -/** JVM implementation — returns System.currentTimeMillis() (no lifecycle-based updates on Desktop). */ -@Composable actual fun rememberTimeTickWithLifecycle(): Long = System.currentTimeMillis() +/** JVM implementation — returns the current epoch millis (no lifecycle-based updates on Desktop). */ +@Composable actual fun rememberTimeTickWithLifecycle(): Long = nowMillis 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 aa3435d29..a938f92ea 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -20,11 +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.MeshtasticUri +import org.meshtastic.core.common.util.ioDispatcher import java.awt.Desktop import java.awt.FileDialog import java.awt.Frame @@ -61,7 +60,7 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url -> /** JVM — Opens a native file dialog to save a file. */ @Composable actual fun rememberSaveFileLauncher( - onUriReceived: (MeshtasticUri) -> Unit, + onUriReceived: (CommonUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ -> val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE) dialog.file = defaultFilename @@ -70,7 +69,7 @@ actual fun rememberSaveFileLauncher( val dir = dialog.directory if (file != null && dir != null) { val path = File(dir, file) - onUriReceived(MeshtasticUri(path.toURI().toString())) + onUriReceived(CommonUri.parse(path.toURI().toString())) } } @@ -83,14 +82,14 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT val dir = dialog.directory if (file != null && dir != null) { val path = File(dir, file) - onUriReceived(CommonUri(path.toURI())) + onUriReceived(CommonUri.parse(path.toURI().toString())) } } /** 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())) @@ -130,3 +129,19 @@ actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () @Composable actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } } + +/** JVM no-op — Desktop does not require runtime Bluetooth permissions. */ +@Composable +actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { onGranted() } + +/** JVM no-op — Desktop does not require runtime notification permissions. */ +@Composable +actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { + onGranted() +} + +/** JVM — location permission is always considered granted on Desktop. */ +@Composable actual fun isLocationPermissionGranted(): Boolean = true + +/** JVM — GPS is never disabled on Desktop (concept doesn't apply). */ +@Composable actual fun isGpsDisabled(): Boolean = false diff --git a/desktop/README.md b/desktop/README.md index 129f49e94..975cd59e2 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -25,14 +25,18 @@ A Compose Desktop application target — the first full non-Android target for t ## ProGuard / Minification -Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source. +Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source. Rules are aligned with the Android R8 rules in `app/proguard-rules.pro` — both targets share the same anti-class-merging philosophy. **Configuration:** - `build.gradle.kts` — `buildTypes.release.proguard` block enables ProGuard with `optimize.set(true)` and `obfuscate.set(false)`. -- `proguard-rules.pro` — Comprehensive keep-rules for all reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources). +- `proguard-rules.pro` — Keep-rules for reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP `androidx.room3`, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources) and an anti-merge rule for Compose animation classes. + +**Key rules:** +- **Compose animation anti-merge** (`-keep class androidx.compose.animation.** { *; }`) — Prevents ProGuard's optimizer from incorrectly tree-shaking or merging animation class hierarchies (e.g. `EnterTransition`/`ExitTransition` into `*Impl`), which causes animations to silently snap. Same rule as Android. +- **Room KMP** — Uses `androidx.room3` package path (Room KMP 3.x). **Troubleshooting ProGuard issues:** -- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro`. +- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro` **and** the corresponding rule in `app/proguard-rules.pro` to keep both targets aligned. - To debug which classes ProGuard removes, temporarily add `-printusage proguard-usage.txt` to the rules file and inspect the output in `desktop/proguard-usage.txt`. - To see the full mapping of optimizations applied, add `-printseeds proguard-seeds.txt`. - Run `./gradlew :desktop:runRelease` for a quick smoke-test of the minified app before packaging. diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index df5122a4d..58caf800b 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -127,7 +127,10 @@ compose.desktop { isEnabled.set(true) obfuscate.set(false) // Open-source project — obfuscation adds no value optimize.set(true) - configurationFiles.from(project.file("proguard-rules.pro")) + configurationFiles.from( + rootProject.file("config/proguard/shared-rules.pro"), + project.file("proguard-rules.pro"), + ) } nativeDistributions { @@ -158,9 +161,14 @@ compose.desktop { iconFile.set(project.file("src/main/resources/icon.icns")) minimumSystemVersion = "12.0" bundleID = "org.meshtastic.desktop" + entitlementsFile.set(project.file("entitlements.plist")) infoPlist { extraKeysRawXml = """ + NSBluetoothAlwaysUsageDescription + Meshtastic uses Bluetooth to communicate with your Meshtastic radio device. + NSLocalNetworkUsageDescription + Meshtastic uses your local network to discover Meshtastic devices connected via WiFi. NSUserNotificationAlertStyle alert CFBundleURLTypes diff --git a/desktop/entitlements.plist b/desktop/entitlements.plist new file mode 100644 index 000000000..f799a66e9 --- /dev/null +++ b/desktop/entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.bluetooth + + + diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index a73c347d1..280214b2e 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -4,201 +4,57 @@ # Open-source project: we rely on tree-shaking (unused code removal) for size # reduction. Obfuscation is disabled in build.gradle.kts (obfuscate.set(false)). # -# Key libraries requiring keep-rules (reflection, JNI, code generation): -# Koin (DI via reflection), kotlinx-serialization (generated serializers), -# Wire protobuf (ADAPTER reflection), Room KMP (generated DB + converters), -# Ktor (Java engine + ServiceLoader), Kable BLE, Coil, Compose Multiplatform -# resources, SQLite bundled (JNI), AboutLibraries. +# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, +# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, +# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in +# config/proguard/shared-rules.pro and are wired in by this module's +# build.gradle.kts. This file holds only desktop/JVM-specific rules. # ============================================================================ # ---- General ---------------------------------------------------------------- -# Preserve line numbers for meaningful stack traces --keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions - # Suppress notes about duplicate resource files (common in fat JARs) -dontnote ** +# Disable ProGuard optimization passes. Tree-shaking (unused code removal) still +# runs — only method-body rewrites and call-site transformations are suppressed. +# +# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on +# Composer.() and ComposerImpl.(), plus -assumevalues on +# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives +# let the optimizer rewrite *call sites* (class-init triggers, flag reads) even +# when the target classes are preserved by -keep rules. The result is that the +# Compose recomposer/frame-clock/animation state machines silently freeze on +# their first frame in release builds. -dontoptimize is the only directive that +# disables processing of -assumenosideeffects/-assumevalues. The desktop compose +# build sets optimize.set(true), so this applies here as well as to R8. See #5146. +-dontoptimize + # Do not parse/rewrite Kotlin metadata during shrinking/optimization. # ProGuard's KotlinShrinker cannot handle the metadata produced by Compose # Multiplatform 1.11.x + Kotlin 2.3.x, causing a NullPointerException. # Since we disable obfuscation (class names remain stable), metadata references # stay valid and do not need rewriting. The annotations themselves are preserved # by -keepattributes *Annotation*. +# +# NOTE: -dontprocesskotlinmetadata is a ProGuard-only directive; R8 does not +# recognize it, which is why it lives in the desktop-only file. -dontprocesskotlinmetadata # ---- Entry point ------------------------------------------------------------ -keep class org.meshtastic.desktop.MainKt { *; } -# ---- Kotlin / Coroutines --------------------------------------------------- +# ---- Ktor Java engine (desktop-only; Android uses OkHttp) ------------------- +# io.ktor.client.engine.java ships consumer rules; the shared +# HttpClientEngineFactory ServiceLoader keep in shared-rules.pro covers the +# reflective discovery path. -# Keep Kotlin metadata for reflection-dependent libraries --keep class kotlin.Metadata { *; } --keep class kotlin.reflect.** { *; } - -# Coroutines internals --dontwarn kotlinx.coroutines.** --keep class kotlinx.coroutines.** { *; } --keep class kotlin.coroutines.Continuation { *; } - -# ---- Koin DI (reflection-based injection) ----------------------------------- - -# Koin core — uses reflection to instantiate definitions --keep class org.koin.** { *; } --dontwarn org.koin.** - -# Keep all Koin-annotated @Module / @ComponentScan classes and their generated -# counterparts so Koin K2 plugin output survives tree-shaking. --keep @org.koin.core.annotation.Module class * { *; } --keep @org.koin.core.annotation.ComponentScan class * { *; } --keep @org.koin.core.annotation.Single class * { *; } --keep @org.koin.core.annotation.Factory class * { *; } - -# Generated Koin module extensions (K2 plugin output) --keep class org.meshtastic.**.di.** { *; } - -# ---- kotlinx-serialization -------------------------------------------------- - -# The serialization plugin generates companion $serializer classes and -# serializer() factory methods that are invoked reflectively. --keepattributes RuntimeVisibleAnnotations --keep class kotlinx.serialization.** { *; } --dontwarn kotlinx.serialization.** - -# Keep @Serializable classes and their generated serializers --keepclassmembers @kotlinx.serialization.Serializable class ** { - # Companion object that holds the serializer() factory - static ** Companion; - kotlinx.serialization.KSerializer serializer(...); -} --keepclassmembers class **.$serializer { *; } --keep class **.$serializer { *; } --keepclasseswithmembers class ** { - kotlinx.serialization.KSerializer serializer(...); -} - -# ---- Wire protobuf ---------------------------------------------------------- - -# Wire generates ADAPTER companion objects accessed via reflection --keep class com.squareup.wire.** { *; } --dontwarn com.squareup.wire.** - -# All generated proto message classes --keep class org.meshtastic.proto.** { *; } --keep class meshtastic.** { *; } - -# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs) --dontwarn android.os.Parcel** --dontwarn android.os.Parcelable** - -# ---- Room KMP --------------------------------------------------------------- - -# Preserve generated database constructors (required for Room's reflective init) --keep class * extends androidx.room3.RoomDatabase { (); } --keep class * implements androidx.room3.RoomDatabaseConstructor { *; } - -# Keep the expect/actual MeshtasticDatabaseConstructor --keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } --keep class org.meshtastic.core.database.MeshtasticDatabase { *; } - -# Room DAOs — Room generates implementations at compile time; keep interfaces --keep class org.meshtastic.core.database.dao.** { *; } - -# Room Entities — accessed via reflection for column mapping --keep class org.meshtastic.core.database.entity.** { *; } - -# Room TypeConverters — invoked reflectively --keep class org.meshtastic.core.database.Converters { *; } - -# Room generated _Impl classes --keep class **_Impl { *; } - -# ---- SQLite bundled (JNI) --------------------------------------------------- - --keep class androidx.sqlite.** { *; } --dontwarn androidx.sqlite.** - -# ---- Ktor (Java engine + ServiceLoader + content negotiation) --------------- - -# Ktor uses ServiceLoader and reflection for engine/plugin discovery --keep class io.ktor.** { *; } --dontwarn io.ktor.** - -# Keep ServiceLoader metadata files --keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } - -# Java HTTP client engine --keep class io.ktor.client.engine.java.** { *; } - -# ---- Coil (image loading) --------------------------------------------------- - --keep class coil3.** { *; } --dontwarn coil3.** - -# ---- Kable BLE -------------------------------------------------------------- - --keep class com.juul.kable.** { *; } --dontwarn com.juul.kable.** - -# ---- Compose Multiplatform resources ---------------------------------------- - -# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.) --keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.** { *; } - -# ---- AboutLibraries --------------------------------------------------------- - --keep class com.mikepenz.aboutlibraries.** { *; } --dontwarn com.mikepenz.aboutlibraries.** - -# ---- Multiplatform Markdown Renderer ---------------------------------------- - --keep class com.mikepenz.markdown.** { *; } --dontwarn com.mikepenz.markdown.** - -# ---- QR Code Kotlin --------------------------------------------------------- - --keep class io.github.g0dkar.qrcode.** { *; } --dontwarn io.github.g0dkar.qrcode.** --keep class qrcode.** { *; } --dontwarn qrcode.** - -# ---- Kermit logging ---------------------------------------------------------- - --keep class co.touchlab.kermit.** { *; } --dontwarn co.touchlab.kermit.** - -# ---- Okio ------------------------------------------------------------------- - --dontwarn okio.** --keep class okio.** { *; } - -# ---- DataStore -------------------------------------------------------------- - --keep class androidx.datastore.** { *; } --dontwarn androidx.datastore.** - -# ---- Paging ----------------------------------------------------------------- - --keep class androidx.paging.** { *; } --dontwarn androidx.paging.** - -# ---- Lifecycle / Navigation / ViewModel (JetBrains forks) ------------------- - --keep class androidx.lifecycle.** { *; } --keep class androidx.navigation3.** { *; } --dontwarn androidx.lifecycle.** --dontwarn androidx.navigation3.** - -# ---- Meshtastic application code -------------------------------------------- +# ---- Meshtastic desktop host shell ------------------------------------------ # Keep all desktop module classes (thin host shell — not worth tree-shaking) -keep class org.meshtastic.desktop.** { *; } -# Core model classes (used in serialization, Room, and Koin injection) --keep class org.meshtastic.core.model.** { *; } - # ---- JVM runtime suppression ------------------------------------------------ -dontwarn java.lang.reflect.** diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 26fa16f6e..e3c7f8b19 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.desktop +import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -25,15 +26,22 @@ import org.meshtastic.core.repository.NotificationPrefs import androidx.compose.ui.window.Notification as ComposeNotification /** - * Desktop notification manager. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid - * double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. + * Desktop notification manager that bridges domain [Notification] objects to Compose Desktop tray notifications. + * + * Notifications are emitted via [notifications] and collected by the tray composable in [Main.kt]. Respects user + * preferences for message, node-event, and low-battery categories. + * + * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the + * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. */ class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager { init { - co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" } + Logger.i { "DesktopNotificationManager initialized" } } private val _notifications = MutableSharedFlow(extraBufferCapacity = 10) + + /** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */ val notifications: SharedFlow = _notifications.asSharedFlow() override fun dispatch(notification: Notification) { @@ -46,9 +54,7 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific Notification.Category.Service -> true } - co.touchlab.kermit.Logger.d { - "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" - } + Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" } if (!enabled) return @@ -61,14 +67,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific } val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType)) - co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" } + Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" } } override fun cancel(id: Int) { - // Desktop Tray notifications cannot be cancelled once sent via TrayState + // Desktop tray notifications cannot be cancelled once sent via TrayState. } override fun cancelAll() { - // Desktop Tray notifications cannot be cleared once sent via TrayState + // Desktop tray notifications cannot be cleared once sent via TrayState. } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 0a450c007..026f0a100 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -18,7 +18,6 @@ package org.meshtastic.desktop import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -27,22 +26,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberWindowState @@ -52,20 +51,30 @@ import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory import coil3.disk.DiskCache import coil3.memory.MemoryCache +import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder +import coil3.util.DebugLogger import io.ktor.client.HttpClient import kotlinx.coroutines.flow.first import okio.Path.Companion.toPath -import org.jetbrains.skia.Image +import org.jetbrains.compose.resources.decodeToSvgPainter +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import org.koin.core.context.startKoin -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.BuildConfigProvider +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.desktopDataDir +import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.desktop_tray_quit +import org.meshtastic.core.resources.desktop_tray_show +import org.meshtastic.core.resources.desktop_tray_tooltip import org.meshtastic.core.service.MeshServiceOrchestrator import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.viewmodel.UIViewModel @@ -75,33 +84,51 @@ import org.meshtastic.desktop.di.desktopPlatformModule import org.meshtastic.desktop.ui.DesktopMainScreen import java.awt.Desktop import java.util.Locale +import coil3.util.Logger as CoilLogger /** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */ -private val LocalAppLocale = staticCompositionLocalOf { "" } - private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB +/** + * Loads an SVG from JVM classpath resources and returns a [Painter]. + * + * Uses the CMP 1.11 `decodeToSvgPainter` extension which replaces the deprecated `useResource`/`loadSvgPainter` pair. + * The SVG bytes are read from the classpath because CMP `composeResources/` only supports XML vector drawables and + * raster images — not raw SVGs. Since the desktop module is a JVM-only host shell, classpath resource access is safe. + */ @Composable -private fun classpathPainterResource(path: String): Painter { - val bitmap: ImageBitmap = - remember(path) { - val bytes = Thread.currentThread().contextClassLoader!!.getResourceAsStream(path)!!.readAllBytes() - Image.makeFromEncoded(bytes).toComposeImageBitmap() +private fun svgPainterResource(path: String, density: Density): Painter = remember(path, density) { + val classLoader = + requireNotNull(Thread.currentThread().contextClassLoader) { + "Missing context class loader while loading resource: $path" } - return remember(bitmap) { BitmapPainter(bitmap) } + val bytes = + requireNotNull(classLoader.getResourceAsStream(path)) { "Missing classpath resource: $path" } + .use { it.readAllBytes() } + bytes.decodeToSvgPainter(density) } -@Suppress("LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalCoilApi::class) fun main(args: Array) = application(exitProcessOnExit = false) { - Logger.i { "Meshtastic Desktop — Starting" } - - val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } } + val koinApp = remember { + Logger.i { "Meshtastic Desktop — Starting" } + startKoin { modules(desktopPlatformModule(), desktopModule()) } + } val systemLocale = remember { Locale.getDefault() } val uiViewModel = remember { koinApp.koin.get() } val httpClient = remember { koinApp.koin.get() } + DeepLinkHandler(args, uiViewModel) + MeshServiceLifecycle() + ThemeAndLocaleProvider(uiViewModel) +} + +// ----- Deep link handling ----- + +/** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */ +@Composable +private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: UIViewModel) { LaunchedEffect(args) { args.forEach { arg -> if ( @@ -109,7 +136,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) { arg.startsWith("http://meshtastic.org") || arg.startsWith("https://meshtastic.org") ) { - uiViewModel.handleDeepLink(MeshtasticUri(arg)) { + uiViewModel.handleDeepLink(CommonUri.parse(arg)) { Logger.e { "Invalid Meshtastic URI passed via args: $arg" } } } @@ -120,21 +147,36 @@ fun main(args: Array) = application(exitProcessOnExit = false) { if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) { Desktop.getDesktop().setOpenURIHandler { event -> val uriStr = event.uri.toString() - uiViewModel.handleDeepLink(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } + uiViewModel.handleDeepLink(CommonUri.parse(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } } } } +} - val meshServiceController = remember { koinApp.koin.get() } +// ----- Mesh service lifecycle ----- + +/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal. */ +@Composable +private fun MeshServiceLifecycle() { + val meshServiceController = koinInject() DisposableEffect(Unit) { meshServiceController.start() onDispose { meshServiceController.stop() } } +} - val uiPrefs = remember { koinApp.koin.get() } +// ----- Theme, locale, and application shell ----- + +/** Resolves the user's theme/locale preferences and renders the full application UI. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { + val systemLocale = remember { Locale.getDefault() } + val uiPrefs = koinInject() val themePref by uiPrefs.theme.collectAsState(initial = -1) val localePref by uiPrefs.locale.collectAsState(initial = "") - + val contrastLevelValue by uiPrefs.contrastLevel.collectAsState(initial = 0) + val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale) val isDarkTheme = @@ -144,25 +186,63 @@ fun main(args: Array) = application(exitProcessOnExit = false) { else -> isSystemInDarkTheme() } + MeshtasticDesktopApp(uiViewModel, isDarkTheme, contrastLevel) +} + +// ----- Application chrome (tray, window, navigation) ----- + +/** Composes the system tray, window, and Coil image loader. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.MeshtasticDesktopApp( + uiViewModel: UIViewModel, + isDarkTheme: Boolean, + contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, +) { var isAppVisible by remember { mutableStateOf(true) } var isWindowReady by remember { mutableStateOf(false) } val trayState = rememberTrayState() - val appIcon = classpathPainterResource("icon.png") + val density = LocalDensity.current + val appIcon = svgPainterResource("tray_icon_black.svg", density) - @Suppress("DEPRECATION") val trayIcon = - androidx.compose.ui.res.painterResource( - if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", - ) + svgPainterResource(if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", density) - val notificationManager = remember { koinApp.koin.get() } - val desktopPrefs = remember { koinApp.koin.get() } + val notificationManager = koinInject() + val desktopPrefs = koinInject() val windowState = rememberWindowState() LaunchedEffect(Unit) { notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) } } + WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true } + + Tray( + state = trayState, + icon = trayIcon, + tooltip = stringResource(Res.string.desktop_tray_tooltip), + onAction = { isAppVisible = true }, + menu = { + Item(stringResource(Res.string.desktop_tray_show), onClick = { isAppVisible = true }) + Item(stringResource(Res.string.desktop_tray_quit), onClick = ::exitApplication) + }, + ) + + if (isWindowReady && isAppVisible) { + MeshtasticWindow(uiViewModel, isDarkTheme, contrastLevel, appIcon, windowState) { isAppVisible = false } + } +} + +// ----- Window bounds persistence ----- + +/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */ +@Composable +private fun WindowBoundsManager( + desktopPrefs: DesktopPreferencesDataSource, + windowState: WindowState, + onReady: () -> Unit, +) { LaunchedEffect(Unit) { val initialWidth = desktopPrefs.windowWidth.first() val initialHeight = desktopPrefs.windowHeight.first() @@ -177,7 +257,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) { WindowPosition(Alignment.Center) } - isWindowReady = true + onReady() snapshotFlow { val x = if (windowState.position.isSpecified) windowState.position.x.value else Float.NaN @@ -188,86 +268,107 @@ fun main(args: Array) = application(exitProcessOnExit = false) { desktopPrefs.setWindowBounds(width = bounds[0], height = bounds[1], x = bounds[2], y = bounds[3]) } } +} - Tray( - state = trayState, - icon = trayIcon, - tooltip = "Meshtastic Desktop", - onAction = { isAppVisible = true }, - menu = { - Item("Show Meshtastic", onClick = { isAppVisible = true }) - Item("Quit", onClick = ::exitApplication) - }, - ) +// ----- Main window with keyboard shortcuts and Coil ----- - if (isWindowReady && isAppVisible) { - val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) - val backStack = multiBackstack.activeBackStack +/** Renders the main application window with keyboard shortcuts, Coil image loading, and the Compose UI tree. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ApplicationScope.MeshtasticWindow( + uiViewModel: UIViewModel, + isDarkTheme: Boolean, + contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, + appIcon: Painter, + windowState: WindowState, + onCloseRequest: () -> Unit, +) { + val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) - Window( - onCloseRequest = { isAppVisible = false }, - title = "Meshtastic Desktop", - icon = appIcon, - state = windowState, - onPreviewKeyEvent = { event -> - if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false - when { - event.key == Key.Q -> { - exitApplication() - true - } - event.key == Key.Comma -> { - if ( - TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull()) - ) { - multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) - } - true - } - event.key == Key.One -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) - true - } - event.key == Key.Two -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) - true - } - event.key == Key.Three -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) - true - } - event.key == Key.Four -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) - true - } - event.key == Key.Slash -> { - backStack.add(SettingsRoute.About) - true - } - else -> false - } - }, - ) { - setSingletonImageLoaderFactory { context -> - val cacheDir = desktopDataDir() + "/image_cache_v3" - ImageLoader.Builder(context) - .components { - add(KtorNetworkFetcherFactory(httpClient = httpClient)) - // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts - // that show up as solid/black hardware images. - add(SvgDecoder.Factory(renderToBitmap = true)) - } - .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } - .diskCache { - DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() - } - .crossfade(true) - .build() - } - - CompositionLocalProvider(LocalAppLocale provides localePref) { - AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } - } + Window( + onCloseRequest = onCloseRequest, + title = "Meshtastic Desktop", + icon = appIcon, + state = windowState, + onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) }, + ) { + CoilImageLoaderSetup() + AppTheme(darkTheme = isDarkTheme, contrastLevel = contrastLevel) { + DesktopMainScreen(uiViewModel, multiBackstack) } } } + +/** Configures the Coil singleton [ImageLoader] with Ktor networking, SVG decoding, and caching. */ +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun CoilImageLoaderSetup() { + val httpClient = koinInject() + val buildConfigProvider = koinInject() + + setSingletonImageLoaderFactory { context -> + val cacheDir = desktopDataDir() + "/image_cache_v3" + ImageLoader.Builder(context) + .components { + add( + KtorNetworkFetcherFactory( + httpClient = httpClient, + concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(), + ), + ) + // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts + // that show up as solid/black hardware images. + add(SvgDecoder.Factory(renderToBitmap = true)) + } + .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } + .diskCache { DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() } + .logger(if (buildConfigProvider.isDebug) DebugLogger(minLevel = CoilLogger.Level.Verbose) else null) + .crossfade(true) + .build() + } +} + +// ----- Keyboard shortcuts ----- + +/** Handles Cmd-key shortcuts. Returns `true` if the event was consumed. */ +private fun handleKeyboardShortcut( + event: androidx.compose.ui.input.key.KeyEvent, + multiBackstack: MultiBackstack, + exitApplication: () -> Unit, +): Boolean { + if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return false + val backStack = multiBackstack.activeBackStack + return when (event.key) { + Key.Q -> { + exitApplication() + true + } + Key.Comma -> { + if (TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())) { + multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) + } + true + } + Key.One -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) + true + } + Key.Two -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) + true + } + Key.Three -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) + true + } + Key.Four -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) + true + } + Key.Slash -> { + backStack.add(SettingsRoute.About) + true + } + else -> false + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt index 9af34f28d..6dd562bd4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt @@ -21,7 +21,6 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -30,16 +29,21 @@ 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.di.CoroutineDispatchers -const val KEY_WINDOW_WIDTH = "window_width" -const val KEY_WINDOW_HEIGHT = "window_height" -const val KEY_WINDOW_X = "window_x" -const val KEY_WINDOW_Y = "window_y" - +/** + * Persists and restores desktop window geometry (position and size) across application restarts. + * + * Backed by the `CorePreferencesDataStore` [DataStore] instance. Window bounds are written atomically via + * [setWindowBounds] and exposed as [StateFlow] properties for composable consumption. + */ @Single -class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore) { +class DesktopPreferencesDataSource( + @Named("CorePreferencesDataStore") private val dataStore: DataStore, + dispatchers: CoroutineDispatchers, +) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) val windowWidth: StateFlow = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f) val windowHeight: StateFlow = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f) @@ -64,9 +68,9 @@ class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private va ): StateFlow = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default) companion object { - val WINDOW_WIDTH = floatPreferencesKey(KEY_WINDOW_WIDTH) - val WINDOW_HEIGHT = floatPreferencesKey(KEY_WINDOW_HEIGHT) - val WINDOW_X = floatPreferencesKey(KEY_WINDOW_X) - val WINDOW_Y = floatPreferencesKey(KEY_WINDOW_Y) + val WINDOW_WIDTH = floatPreferencesKey("window_width") + val WINDOW_HEIGHT = floatPreferencesKey("window_height") + val WINDOW_X = floatPreferencesKey("window_x") + val WINDOW_Y = floatPreferencesKey("window_y") } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt index 0bb5311aa..d27f6d5d9 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt @@ -19,6 +19,10 @@ package org.meshtastic.desktop.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +/** + * Koin module that component-scans the `org.meshtastic.desktop` package for annotated bindings (`@Single`, `@Factory`, + * `@KoinViewModel`). + */ @Module @ComponentScan("org.meshtastic.desktop") class DesktopDiModule diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 336f87b54..8ac634112 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -14,14 +14,22 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress( + "ktlint:standard:no-unused-imports", +) // Koin K2 compiler plugin generates aliased module extensions referenced in desktopModule() + package org.meshtastic.desktop.di // Generated Koin module extensions from core KMP modules import io.ktor.client.HttpClient import io.ktor.client.engine.java.Java +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 org.koin.dsl.module @@ -32,6 +40,7 @@ import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.RadioController +import org.meshtastic.core.network.HttpClientDefaults import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.network.service.ApiService @@ -163,7 +172,7 @@ private fun desktopPlatformStubsModule() = module { single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } single { NoopMeshWorkerManager() } - single { DesktopMessageQueue(packetRepository = get(), radioController = get()) } + single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) } single { NoopMeshLocationManager() } single { NoopLocationRepository() } single { NoopMQTTRepository() } @@ -178,10 +187,20 @@ private fun desktopPlatformStubsModule() = module { single { HttpClient(Java) { install(ContentNegotiation) { json(get()) } + install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) } + install(HttpTimeout) { + requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS + } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES) + exponentialDelay() + } if (DesktopBuildConfig.IS_DEBUG) { install(Logging) { logger = KermitHttpLogger - level = LogLevel.HEADERS + level = LogLevel.BODY } } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt index 6b0aa1b2a..743c2065d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -27,7 +27,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okio.FileSystem import okio.Path.Companion.toPath @@ -35,10 +34,12 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.database.desktopDataDir +import org.meshtastic.core.datastore.di.DATASTORE_SCOPE import org.meshtastic.core.datastore.serializer.ChannelSetSerializer import org.meshtastic.core.datastore.serializer.LocalConfigSerializer import org.meshtastic.core.datastore.serializer.LocalStatsSerializer import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.LocalConfig @@ -49,10 +50,10 @@ import org.meshtastic.proto.LocalStats private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore { val dir = desktopDataDir() + "/datastore" FileSystem.SYSTEM.createDirectories(dir.toPath()) - return PreferenceDataStoreFactory.create( + return PreferenceDataStoreFactory.createWithPath( corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), scope = scope, - produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath().toFile() }, + produceFile = { "$dir/$name.preferences_pb".toPath() }, ) } @@ -80,16 +81,15 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner { * - [Lifecycle] (`ProcessLifecycle`) * - [BuildConfigProvider] */ -@Suppress("InjectDispatcher") fun desktopPlatformModule() = module { // Application-lifetime scope shared by all DataStore instances. Per the DataStore docs: // "The Job within this context dictates the lifecycle of the DataStore's internal operations. // Ensure it is an application-scoped context that is not canceled by UI lifecycle events." // DataStore has no close() API — the in-memory cache is released only when this Job is cancelled // (at process exit). Using SupervisorJob so a single store's failure doesn't cascade. - val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + single(named(DATASTORE_SCOPE)) { CoroutineScope(get().io + SupervisorJob()) } - includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope)) + includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule()) // -- Build config (values generated at build time by generateDesktopBuildConfig) -- single { @@ -108,30 +108,50 @@ fun desktopPlatformModule() = module { } /** Named [DataStore]<[Preferences]> instances for all preference domains. */ -private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module { - single>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) } +private fun desktopPreferencesDataStoreModule() = module { + single>(named("AnalyticsDataStore")) { + createPreferencesDataStore("analytics", get(named(DATASTORE_SCOPE))) + } single>(named("HomoglyphEncodingDataStore")) { - createPreferencesDataStore("homoglyph_encoding", scope) + createPreferencesDataStore("homoglyph_encoding", get(named(DATASTORE_SCOPE))) + } + single>(named("AppDataStore")) { + createPreferencesDataStore("app", get(named(DATASTORE_SCOPE))) + } + single>(named("CustomEmojiDataStore")) { + createPreferencesDataStore("custom_emoji", get(named(DATASTORE_SCOPE))) + } + single>(named("MapDataStore")) { + createPreferencesDataStore("map", get(named(DATASTORE_SCOPE))) + } + single>(named("MapConsentDataStore")) { + createPreferencesDataStore("map_consent", get(named(DATASTORE_SCOPE))) } - single>(named("AppDataStore")) { createPreferencesDataStore("app", scope) } - single>(named("CustomEmojiDataStore")) { createPreferencesDataStore("custom_emoji", scope) } - single>(named("MapDataStore")) { createPreferencesDataStore("map", scope) } - single>(named("MapConsentDataStore")) { createPreferencesDataStore("map_consent", scope) } single>(named("MapTileProviderDataStore")) { - createPreferencesDataStore("map_tile_provider", scope) + createPreferencesDataStore("map_tile_provider", get(named(DATASTORE_SCOPE))) + } + single>(named("MeshDataStore")) { + createPreferencesDataStore("mesh", get(named(DATASTORE_SCOPE))) + } + single>(named("RadioDataStore")) { + createPreferencesDataStore("radio", get(named(DATASTORE_SCOPE))) + } + single>(named("UiDataStore")) { + createPreferencesDataStore("ui", get(named(DATASTORE_SCOPE))) + } + single>(named("MeshLogDataStore")) { + createPreferencesDataStore("meshlog", get(named(DATASTORE_SCOPE))) + } + single>(named("FilterDataStore")) { + createPreferencesDataStore("filter", get(named(DATASTORE_SCOPE))) } - single>(named("MeshDataStore")) { createPreferencesDataStore("mesh", scope) } - single>(named("RadioDataStore")) { createPreferencesDataStore("radio", scope) } - single>(named("UiDataStore")) { createPreferencesDataStore("ui", scope) } - single>(named("MeshLogDataStore")) { createPreferencesDataStore("meshlog", scope) } - single>(named("FilterDataStore")) { createPreferencesDataStore("filter", scope) } single>(named("CorePreferencesDataStore")) { - createPreferencesDataStore("core_preferences", scope) + createPreferencesDataStore("core_preferences", get(named(DATASTORE_SCOPE))) } } /** Proto [DataStore] instances (OkioStorage-backed). */ -private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { +private fun desktopProtoDataStoreModule() = module { val protoDir = desktopDataDir() + "/datastore" single>(named("CoreLocalConfigDataStore")) { @@ -143,7 +163,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/local_config.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -156,7 +176,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/module_config.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -169,7 +189,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/channel_set.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } @@ -182,7 +202,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { producePath = { "$protoDir/local_stats.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }), - scope = scope, + scope = get(named(DATASTORE_SCOPE)), ) } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index f30ecb66b..594a62bc4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -19,6 +19,7 @@ package org.meshtastic.desktop.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.map.navigation.mapGraph @@ -29,42 +30,22 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph /** - * Registers entry providers for all top-level desktop destinations. + * Registers [NavKey] entry providers for every desktop destination. * - * Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from - * `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via - * [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their - * shared composables are wired. + * Each call delegates to the shared navigation graph extension exported by the corresponding feature module, keeping + * the desktop shell free of screen-level composable knowledge. */ -fun EntryProviderScope.desktopNavGraph( - backStack: NavBackStack, - uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel, -) { - // Nodes — real composables from feature:node +fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack, uiViewModel: UIViewModel) { nodesGraph( backStack = backStack, scrollToTopEvents = uiViewModel.scrollToTopEventFlow, onHandleDeepLink = uiViewModel::handleDeepLink, ) - - // Conversations — real composables from feature:messaging contactsGraph(backStack, uiViewModel.scrollToTopEventFlow) - - // Map — placeholder for now, will be replaced with feature:map real implementation mapGraph(backStack) - - // Firmware — in-flow destination (for example from Settings), not a top-level rail tab firmwareGraph(backStack) - - // Settings — real composables from feature:settings settingsGraph(backStack) - - // Channels channelsGraph(backStack) - - // Connections — shared screen connectionsGraph(backStack) - - // WiFi Provisioning — nymea-networkmanager BLE protocol wifiProvisionGraph(backStack) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index a5ec5b795..4cda00251 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -16,11 +16,13 @@ */ package org.meshtastic.desktop.notification +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.desktop_notification_title import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.low_battery_message import org.meshtastic.core.resources.low_battery_title @@ -29,8 +31,15 @@ import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry /** - * Desktop notifications implementation. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to - * avoid double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. + * Desktop implementation of [MeshServiceNotifications]. + * + * Converts mesh-layer notification events into domain [Notification] objects and dispatches them through + * [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications. + * + * Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops. + * + * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the + * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. */ @Suppress("TooManyFunctions") class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications { @@ -39,14 +48,11 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat } override fun initChannels() { - // no-op for desktop + // No-op: desktop has no Android notification channels. } - override fun updateServiceStateNotification( - state: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ) { - // We don't have a foreground service on desktop + override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) { + // No-op: desktop has no foreground service notification. } override suspend fun updateMessageNotification( @@ -106,16 +112,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat ) } - @Suppress("ktlint:standard:max-line-length") override fun showAlertNotification(contactKey: String, name: String, alert: String) { - notificationManager.dispatch( - Notification( - title = name, - message = alert, - category = Notification.Category.Alert, - contactKey = contactKey, - ), - ) + val notification = + Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey) + notificationManager.dispatch(notification) } override fun showNewNodeSeenNotification(node: Node) { @@ -142,7 +142,7 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat override fun showClientNotification(clientNotification: ClientNotification) { notificationManager.dispatch( Notification( - title = "Meshtastic", + title = getString(Res.string.desktop_notification_title), message = clientNotification.message, category = Notification.Category.Alert, id = clientNotification.toString().hashCode(), diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt index c272e7bd9..3888b0af3 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt @@ -18,9 +18,9 @@ package org.meshtastic.desktop.radio import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.RadioController @@ -36,8 +36,9 @@ import org.meshtastic.core.repository.PacketRepository class DesktopMessageQueue( private val packetRepository: PacketRepository, private val radioController: RadioController, + dispatchers: CoroutineDispatchers, ) : MessageQueue { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) override suspend fun enqueue(packetId: Int) { scope.launch { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt index 5e223ed67..b0761522d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt @@ -24,15 +24,18 @@ import org.meshtastic.feature.node.compass.MagneticFieldProvider import org.meshtastic.feature.node.compass.PhoneLocationProvider import org.meshtastic.feature.node.compass.PhoneLocationState +/** No-op [CompassHeadingProvider] — desktop has no compass sensor. */ class NoopCompassHeadingProvider : CompassHeadingProvider { override fun headingUpdates(): Flow = flowOf(HeadingState(hasSensor = false)) } +/** No-op [PhoneLocationProvider] — desktop has no GPS provider. */ class NoopPhoneLocationProvider : PhoneLocationProvider { override fun locationUpdates(): Flow = flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false)) } +/** No-op [MagneticFieldProvider] — always returns zero declination. */ class NoopMagneticFieldProvider : MagneticFieldProvider { override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index 220b21d05..707dfaf03 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.MqttClientProxyMessage +import org.meshtastic.mqtt.ConnectionState as MqttConnectionState import org.meshtastic.proto.Position as ProtoPosition /** @@ -80,6 +81,10 @@ class NoopRadioInterfaceService : RadioInterfaceService { logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)") } + override fun resetReceivedBuffer() { + // No-op: this stub never buffers bytes. + } + override fun connect() { logWarn("NoopRadioInterfaceService.connect()") } @@ -158,6 +163,8 @@ class NoopMQTTRepository : MQTTRepository { override val proxyMessageFlow: Flow = emptyFlow() override fun publish(topic: String, data: ByteArray, retained: Boolean) {} + + override val connectionState = MutableStateFlow(MqttConnectionState.Disconnected.Idle) } // endregion diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 00b2e82c7..a55bf902f 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -31,7 +31,10 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.desktop.navigation.desktopNavGraph -/** Desktop main screen — uses shared navigation components. */ +/** + * Desktop main screen — assembles the shared [MeshtasticAppShell], [MeshtasticNavigationSuite], and + * [MeshtasticNavDisplay] with the desktop-specific [desktopNavGraph] entry provider. + */ @Composable fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) { val backStack = multiBackstack.activeBackStack diff --git a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md index 17b152f4a..d3dd5ad93 100644 --- a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md +++ b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md @@ -121,35 +121,25 @@ kotlin { ``` **What the plugin provides automatically:** -- `commonMain`: `compose-multiplatform-material3`, `compose-multiplatform-materialIconsExtended`, `jetbrains-lifecycle-viewmodel-compose`, `koin-compose-viewmodel`, `kermit` -- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-material-iconsExtended`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview` +- `commonMain`: `compose-multiplatform-material3`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-runtime-compose`, `koin-compose-viewmodel`, `kermit` +- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview` - `commonTest`: `core:testing` **Why:** Eliminates ~15 duplicate dependency declarations per feature module (modelled after Now in Android's `AndroidFeatureImplConventionPlugin`). ### Example: Adding Android-specific test config -**Pattern:** Add to `AndroidLibraryConventionPlugin.kt`: +**Pattern:** Test options (`animationsDisabled`, `testInstrumentationRunner`, `unitTests.isReturnDefaultValues`) are centralized in `configureKotlinAndroid()` via `CommonExtension`, so they apply to both app and library modules automatically. To add new test config, update `KotlinAndroid.kt::configureKotlinAndroid()`: ```kotlin -extensions.configure { - configureKotlinAndroid(this) - testOptions.apply { - animationsDisabled = true - // NEW: Android-specific test config - unitTests.isIncludeAndroidResources = true - } -} -``` - -**Alternative:** If it applies to both app and library, consider extracting a function: - -```kotlin -internal fun Project.configureAndroidTestOptions() { - extensions.configure { - testOptions.apply { +internal fun Project.configureKotlinAndroid( + commonExtension: CommonExtension<*, *, *, *, *, *>, +) { + commonExtension.apply { + testOptions { animationsDisabled = true - // Shared test options + unitTests.isReturnDefaultValues = true + // NEW: Add shared test options here } } } @@ -177,6 +167,8 @@ internal fun Project.configureAndroidTestOptions() { | `AndroidApplicationFlavorsConventionPlugin` ≈ `AndroidLibraryFlavorsConventionPlugin` | **Kept Separate** | Different extension types; small duplication; explicit intent | | `configureKmpTestDependencies()` (7 modules) | **Consolidated** | Large duplication; single source of truth; all KMP modules benefit | | `jvmAndroidMain` hierarchy setup (4 modules) | **Consolidated** | Shared KMP hierarchy pattern; avoids manual `dependsOn(...)` edges and hierarchy warnings | +| `PUBLISHED_MODULES` set (4 usages) | **Consolidated** | Was repeated as `listOf(...)` in 4 places; now a single `setOf(...)` constant in `KotlinAndroid.kt` | +| `SHARED_COMPILER_ARGS` list (2 code paths) | **Consolidated** | Eliminates duplicated `-opt-in` flags between KMP target compilations and `KotlinCompile` task configuration | ## Testing Convention Changes diff --git a/docs/decisions/README.md b/docs/decisions/README.md deleted file mode 100644 index e8916d8a3..000000000 --- a/docs/decisions/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Decision Records - -Architectural decision records and reviews. Each captures context, decision, and consequences. - -| Decision | File | Status | -|---|---|---| -| Architecture review (March 2026) | [`architecture-review-2026-03.md`](./architecture-review-2026-03.md) | Active | -| Navigation 3 parity strategy (Android + Desktop) | [`navigation3-parity-2026-03.md`](./navigation3-parity-2026-03.md) | Active | -| Navigation 3 API alignment audit | [`navigation3-api-alignment-2026-03.md`](./navigation3-api-alignment-2026-03.md) | Active | -| BLE KMP strategy (Kable) | [`ble-strategy.md`](./ble-strategy.md) | Decided | -| Hilt → Koin migration | [`koin-migration.md`](./koin-migration.md) | Complete | -| Testing consolidation (`core:testing`) | [`testing-consolidation-2026-03.md`](./testing-consolidation-2026-03.md) | Complete | - -For the current KMP migration status, see [`docs/kmp-status.md`](../kmp-status.md). -For the forward-looking roadmap, see [`docs/roadmap.md`](../roadmap.md). diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md deleted file mode 100644 index be43f823b..000000000 --- a/docs/decisions/architecture-review-2026-03.md +++ /dev/null @@ -1,256 +0,0 @@ -# Architecture Review — March 2026 - -> Status: **Active** -> Last updated: 2026-03-31 - -Re-evaluation of project modularity and architecture against modern KMP and Android best practices. Identifies gaps and actionable improvements across modularity, reusability, clean abstractions, DI, and testing. - -## Executive Summary - -The codebase is **~98% structurally KMP** — 18/20 core modules and 8/8 feature modules declare `jvm()` targets and cross-compile in CI. Shared `commonMain` code accounts for ~52K LOC vs ~18K platform-specific LOC (a 74/26 split). This is strong. - -Of the five structural gaps originally identified, four are resolved and one remains in progress: - -1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 8 files: `MainActivity`, `MeshUtilApplication`, Nav shell, DI config, and shared map UI components)* -2. ~~**Radio transport layer is app-locked**~~ — ✅ Resolved: `RadioTransport` interface in `core:repository/commonMain`; shared `StreamFrameCodec` + `TcpTransport` in `core:network`. -3. ~~**`java.*` APIs leak into `commonMain`**~~ — ✅ Resolved: `Locale`, `ConcurrentHashMap`, `ReentrantLock` purged. -4. ~~**Zero feature-level `commonTest`**~~ — ✅ Resolved: 193 shared tests across all 8 features; `core:testing` module established. -5. ~~**No `feature:connections` module**~~ — ✅ Resolved: KMP module with shared UI and dynamic transport detection. - -## Source Code Distribution - -| Source set | Files | ~LOC | Purpose | -|---|---:|---:|---| -| `core/*/commonMain` | 337 | 32,700 | Shared business/data logic | -| `feature/*/commonMain` | 146 | 19,700 | Shared feature UI + ViewModels | -| `feature/*/androidMain` | 62 | 14,700 | Platform UI (charts, previews, permissions) | -| `app/src/main` | 8 | ~450 | Android app shell + shared map UI components | -| `desktop/src` | 26 | 4,800 | Desktop app shell | -| `core/*/androidMain` | 49 | 3,500 | Platform implementations | -| `core/*/jvmMain` | 11 | ~500 | JVM actuals | -| `core/*/jvmAndroidMain` | 4 | ~200 | Shared JVM+Android code | - -**Key ratio:** 74% of production code is in `commonMain` (shared). Goal: 85%+. - ---- - -## A. Critical Modularity Gaps - -### A1. `app` module is a God module - -The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host, and shared flavor-agnostic UI. Originally it held **90 files / ~11K LOC**, now reduced to an **8-file shell** (6 original + 2 shared map UI components: `MapButton`, `MapControlsOverlay`): - -| Area | Files | LOC | Where it should live | -|---|---:|---:|---| -| `repository/radio/` | 22 | ~2,000 | `core:service` / `core:network` | -| `service/` | 12 | ~1,500 | Extracted to `core:service/androidMain` ✓ | -| `navigation/` | ~1 | ~200 | Root Nav 3 host wiring stays in `app`. Feature graphs moved to `feature:*`. | -| `settings/` ViewModels | 3 | ~350 | Thin Android wrappers (genuine platform deps) | -| `widget/` | 4 | ~300 | Extracted to `feature:widget` ✓ | -| `worker/` | 4 | ~350 | Extracted to `core:service/androidMain` and `feature:messaging/androidMain` ✓ | -| DI + Application + MainActivity | 5 | ~500 | Stay in `app` ✓ | -| UI screens + ViewModels | 5 | ~1,200 | Stay in `app` (Android-specific deps) | - -**Progress:** Extracted `ChannelViewModel` → `feature:settings/commonMain`, `NodeMapViewModel` → `feature:map/commonMain`, `NodeContextMenu` → `feature:node/commonMain`, `EmptyDetailPlaceholder` → `core:ui/commonMain`. Remaining extractions require radio/service layer refactoring (bigger scope). - -### A2. Radio interface layer is app-locked and non-KMP - -The core transport abstraction was previously locked in `app/repository/radio/` via `IRadioInterface`. This has been successfully refactored: - -1. Defined `RadioTransport` interface in `core:repository/commonMain` (replacing `IRadioInterface`) -2. Moved `StreamFrameCodec`-based framing to `core:network/commonMain` -3. Moved TCP transport to `core:network/jvmAndroidMain` -4. BLE, Serial, and Mock transports now reside in `core:network` and implement `RadioTransport`. - -**Recommended next steps:** -1. Move BLE transport to `core:ble/androidMain` -2. Move Serial/USB transport to `core:service/androidMain` - -### A3. No `feature:connections` module *(resolved 2026-03-12)* - -Device discovery UI was duplicated: -- Android: `app/ui/connections/` (13 files: `ConnectionsScreen`, `ScannerViewModel`, 10 components) -- Desktop: `desktop/ui/connections/DesktopConnectionsScreen.kt` (separate implementation) - -**Outcome:** Created `feature:connections` KMP module with: -- `commonMain`: `ScannerViewModel`, `ConnectionsScreen`, 11 shared UI components, `DeviceListEntry` sealed class, `GetDiscoveredDevicesUseCase` interface, `CommonGetDiscoveredDevicesUseCase` (TCP/recent devices) -- `androidMain`: `AndroidScannerViewModel` (BLE bonding, USB permissions), `AndroidGetDiscoveredDevicesUseCase` (BLE/NSD/USB discovery), `NetworkRepository`, `UsbRepository`, `SerialConnection` -- Desktop uses the shared `ConnectionsScreen` + `CommonGetDiscoveredDevicesUseCase` directly -- Dynamic transport detection via `RadioInterfaceService.supportedDeviceTypes` -- Module registered in both `AppKoinModule` and `DesktopKoinModule` - -### A4. `core:api` AIDL coupling - -`core:api` is Android-only (AIDL IPC). `ServiceClient` in `core:service/androidMain` wraps it. Desktop doesn't use it — it has `DirectRadioControllerImpl` in `core:service/commonMain`. - -**Recommendation:** The `DirectRadioControllerImpl` pattern is correct. Ensure `RadioController` (already in `core:model/commonMain`) is the canonical interface; deprecate the AIDL-based path for in-process usage. - ---- - -## B. KMP Platform Purity - -### B1. `java.util.Locale` leaks in `commonMain` *(resolved 2026-03-11)* - -| File | Usage | -|---|---| -| `core:data/.../TracerouteHandlerImpl.kt` | Replaced with `NumberFormatter.format(seconds, 1)` | -| `core:data/.../NeighborInfoHandlerImpl.kt` | Replaced with `NumberFormatter.format(seconds, 1)` | -| `core:prefs/.../MeshPrefsImpl.kt` | Replaced with locale-free `uppercase()` | - -**Outcome:** The three `Locale` usages identified in March were removed from `commonMain`. Follow-up cleanup in the same sprint also moved `ReentrantLock`-based `SyncContinuation` to `jvmAndroidMain`, replaced prefs `ConcurrentHashMap` caches with atomic persistent maps, and pushed enum reflection behind `expect`/`actual` so no known `java.*` runtime calls remain in `commonMain`. - -### B2. `ConcurrentHashMap` leaks in `commonMain` *(resolved 2026-03-11)* - -Formerly found in 3 prefs files: -- `core:prefs/.../MeshPrefsImpl.kt` -- `core:prefs/.../UiPrefsImpl.kt` -- `core:prefs/.../MapConsentPrefsImpl.kt` - -**Outcome:** These caches now use `AtomicRef>` helpers in `commonMain`, eliminating the last `ConcurrentHashMap` usage from shared prefs code. - -### B3. MQTT (Resolved) - -`MQTTRepositoryImpl` has been migrated to `commonMain` using KMQTT, replacing Eclipse Paho. - -**Fix:** Completed. -- `kmqtt` library integrated for full KMP support. - -### B4. Vico charts *(resolved)* - -Vico chart screens (DeviceMetrics, EnvironmentMetrics, SignalMetrics, PowerMetrics, PaxMetrics) have been migrated to `feature:node/commonMain` using Vico's KMP artifacts (`vico-compose`, `vico-compose-m3`). Desktop wires them via shared composables. No Android-only chart code remains. - -### B5. Cross-platform code deduplication *(resolved 2026-03-21)* - -Comprehensive audit of `androidMain` vs `jvmMain` duplication across all feature modules. Extracted shared components: - -| Component | Module | Eliminated from | -|---|---|---| -| `AlertHost` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` | -| `SharedDialogs` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` | -| `PlaceholderScreen` composable | `core:ui/commonMain` | 4 copies: `desktop/navigation`, `feature:map/jvmMain`, `feature:node/jvmMain` (×2) | -| `ThemePickerDialog` + `ThemeOption` | `feature:settings/commonMain` | Android `SettingsScreen.kt`, Desktop `DesktopSettingsScreen.kt` | -| `formatLogsTo()` + `redactedKeys` | `feature:settings/commonMain` (`LogFormatter.kt`) | Android + Desktop `LogExporter.kt` actuals | -| `handleNodeAction()` | `feature:node/commonMain` | Android `NodeDetailScreen.kt`, Desktop `NodeDetailScreens.kt` | -| `findNodeByNameSuffix()` | `feature:connections/commonMain` | Android USB matcher, TCP recent device matcher | - -Also fixed `Dispatchers.IO` usage in `StoreForwardPacketHandlerImpl` (would break iOS), removed dead `UIViewModel.currentAlert` property, and added `firebase-debug.log` to `.gitignore`. - ---- - -## C. DI Improvements - -### C1. ~~Desktop manual ViewModel wiring~~ *(resolved 2026-03-13)* - -`DesktopKoinModule.kt` originally had ~120 lines of hand-written `viewModel { ... }` blocks. These have been successfully replaced by including Koin modules from `commonMain` generated via the Koin K2 Compiler Plugin for automatic wiring. - -### C2. ~~Desktop stubs lack compile-time validation~~ *(resolved 2026-03-13)* - -`desktopPlatformStubsModule()` previously had stubs that were only validated at runtime. - -**Outcome:** Added `DesktopKoinTest.kt` using Koin's `verify()` API. This test validates the entire Desktop DI graph (including platform stubs and DataStores) during the build. Discovered and fixed missing stubs for `CompassHeadingProvider`, `PhoneLocationProvider`, and `MagneticFieldProvider`. - -### C3. DI module naming convention - -Android uses `@Module`-annotated classes (`CoreDataModule`, `CoreBleAndroidModule`). Desktop imports them as `CoreDataModule().coreDataModule()`. This works but the double-invocation pattern is non-obvious. - -**Recommendation:** Document the pattern in AGENTS.md. Consider if Koin Annotations 2.x supports a simpler import syntax. - ---- - -## D. Test Architecture - -### D1. Zero `commonTest` in feature modules *(resolved 2026-03-12)* - -| Module | `commonTest` | `test`/`androidUnitTest` | -|---|---:|---:| -| `feature:settings` | 33 | 20 | -| `feature:node` | 24 | 9 | -| `feature:messaging` | 21 | 5 | -| `feature:connections` | 27 | 0 | -| `feature:firmware` | 15 | 25 | -| `feature:wifi-provision` | 62 | 0 | - -**Outcome:** All 8 feature modules now have `commonTest` coverage (211 shared tests). Combined with 70 platform unit tests, feature modules have 281 tests total. All Compose UI tests have been migrated from `androidTest` to `commonTest` using CMP `runComposeUiTest`; instrumented test infrastructure has been removed from CI. - -### D2. No shared test fixtures *(resolved 2026-03-12)* - -`core:testing` module established with shared fakes (`FakeNodeRepository`, `FakeServiceRepository`, `FakeRadioController`, `FakePacketRepository`) and `TestDataFactory` builders. Used by all feature `commonTest` suites. - -### D3. Core module test gaps - -36 `commonTest` files exist but are concentrated in `core:domain` (22 files) and `core:data` (10 files). Limited or zero tests in: -- `core:service` (has `ServiceRepositoryImpl`, `DirectRadioControllerImpl`, `MeshServiceOrchestrator`) -- `core:network` (has `StreamFrameCodecTest` — 10 tests; `TcpTransport` untested) -- `core:ble` (connection state machine) -- `core:ui` (utility functions) - -`core:prefs` now has 12 `commonTest` tests (3 files: `FilterPrefsTest`, `TakPrefsTest`, `NotificationPrefsTest`) migrated from `androidHostTest` using Okio + `PreferenceDataStoreFactory.createWithPath()` for KMP compatibility. - -### D4. Desktop has 2 tests - -`desktop/src/test/` contains `DesktopKoinTest.kt` and `DesktopTopLevelDestinationParityTest.kt`. Still needs: -- Navigation graph coverage - ---- - -## E. Module Extraction Priority - -Ordered by impact × effort: - -| Priority | Extraction | Impact | Effort | Enables | -|---:|---|---|---|---| -| 1 | ~~`java.*` purge from `commonMain` (B1, B2)~~ | High | Low | ~~iOS target declaration~~ ✅ Done | -| 2 | Radio transport interfaces to `core:repository` (A2) | High | Medium | Transport unification | -| 3 | `core:testing` shared fixtures (D2) | Medium | Low | Feature commonTest | -| 4 | Feature `commonTest` (D1) | Medium | Medium | KMP test coverage | -| 5 | `feature:connections` (A3) | High | Medium | ~~Desktop connections~~ ✅ Done | -| 6 | Service/worker extraction from `app` (A1) | Medium | Medium | Thin app module | -| 7 | ~~Desktop Koin auto-wiring (C1, C2)~~ | Medium | Low | ✅ Resolved 2026-03-13 | -| 8 | MQTT KMP (B3) | Medium | High | Desktop/iOS MQTT | -| 9 | KMP charts (B4) | Medium | High | Desktop metrics | -| 10 | ~~iOS target declaration~~ | High | Low | ~~CI purity gate~~ ✅ Done | - ---- - -## Scorecard Update - -| Area | Previous | Current | Notes | -|---|---:|---:|---| -| Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared | -| Shared feature/UI logic | 9.5/10 | **9/10** | All 8 KMP features; connections unified; cross-platform deduplication complete | -| Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files | -| Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | -| CI confidence | 8.5/10 | **9/10** | 26 modules validated; feature:connections + feature:wifi-provision + desktop in CI; native release installers | -| DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | — | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | - ---- - -## F. JVM/Desktop Database Lifecycle - -Room KMP's `setAutoCloseTimeout` API is Android-only. On JVM/Desktop, once a Room database is built, its SQLite connections (5 per WAL-mode DB: 4 readers + 1 writer) remain open indefinitely until explicitly closed via `RoomDatabase.close()`. - -### Problem - -When a user switches between multiple mesh devices, the previous device's database remained open in the in-memory cache. Each idle database consumed ~32 MB (connection pool + prepared statement caches), leading to unbounded memory growth proportional to the number of devices ever connected in a session. - -### Solution - -`DatabaseManager.switchActiveDatabase()` now explicitly closes the previously active database via `closeCachedDatabase()` before activating the new one. The closed database is removed from the in-memory cache but its file is preserved, allowing transparent re-opening on next access. - -Additional fixes applied: -1. **Init-order bug**: `dbCache` was declared after `currentDb`, causing NPE during `stateIn`'s `initialValue` evaluation. Reordered to ensure `dbCache` is initialized first. -2. **Corruption handlers**: `ReplaceFileCorruptionHandler` added to `createDatabaseDataStore()` on both JVM and Android, preventing DataStore corruption from crashing the app. -3. **`desktopDataDir()` deduplication**: Made public in `core:database/jvmMain` and removed the duplicate from `DesktopPlatformModule`, establishing a single source of truth for the desktop data directory. -4. **DataStore scope consolidation**: Replaced two separate `CoroutineScope` instances with a single shared `dataStoreScope` in `DesktopPlatformModule`. -5. **Coil cache path**: Desktop `Main.kt` updated to use `desktopDataDir()` instead of hardcoded `user.home`. - ---- - -## References - -- Current migration status: [`kmp-status.md`](./kmp-status.md) -- Roadmap: [`roadmap.md`](./roadmap.md) -- Agent guide: [`../AGENTS.md`](../AGENTS.md) -- Decision records: [`decisions/`](./decisions/) - diff --git a/docs/decisions/navigation3-api-alignment-2026-03.md b/docs/decisions/navigation3-api-alignment-2026-03.md deleted file mode 100644 index 6a0925152..000000000 --- a/docs/decisions/navigation3-api-alignment-2026-03.md +++ /dev/null @@ -1,124 +0,0 @@ - - -# Navigation 3 & Material 3 Adaptive — API Alignment Audit - -**Date:** 2026-03-26 -**Status:** Active -**Scope:** Adoption of Navigation 3 `1.1.0-beta01` Scene APIs, transition metadata, ViewModel scoping, and Material 3 Adaptive integration. -**Supersedes:** [`navigation3-parity-2026-03.md`](navigation3-parity-2026-03.md) Alpha04 Changelog section (versions updated). - -## Current Dependency Baseline - -| Library | Version | Group | -|---|---|---| -| Navigation 3 UI | `1.1.0-beta01` | `org.jetbrains.androidx.navigation3:navigation3-ui` | -| Navigation Event | `1.1.0-alpha01` | `org.jetbrains.androidx.navigationevent:navigationevent-compose` | -| Lifecycle ViewModel Navigation3 | `2.11.0-alpha02` | `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3` | -| Material 3 Adaptive | `1.3.0-alpha06` | `org.jetbrains.compose.material3.adaptive:adaptive*` | -| Material 3 Adaptive Navigation Suite | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3-adaptive-navigation-suite` | -| Compose Multiplatform | `1.11.0-beta01` | `org.jetbrains.compose` | -| Compose Multiplatform Material 3 | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3` | - -## API Audit: What's Available vs. What We Use - -### 1. NavDisplay — Scene Architecture (available since `1.1.0-alpha04`, stable in `beta01`) - -**Available APIs we're NOT using:** - -| API | Purpose | Status in project | -|---|---|---| -| `sceneStrategies: List>` | Allows NavDisplay to render multi-pane Scenes | ✅ Used — `DialogSceneStrategy`, `ListDetailSceneStrategy`, `SupportingPaneSceneStrategy`, `SinglePaneSceneStrategy` | -| `SceneStrategy` interface | Custom scene calculation from backstack entries | ✅ Used via built-in strategies | -| `DialogSceneStrategy` | Renders `entry(metadata = dialog())` entries as overlay Dialogs | ✅ Adopted | -| `SceneDecoratorStrategy` | Wraps/decorates scenes with additional UI | ❌ Not used | -| `NavEntry.metadata` | Attaches typed metadata to entries (transitions, dialog hints, Scene classification) | ✅ Used — `ListDetailSceneStrategy.listPane()`, `.detailPane()`, `.extraPane()` | -| `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-entry custom transitions via metadata | ❌ Not used | -| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ✅ Used — 350 ms crossfade | -| `sharedTransitionScope: SharedTransitionScope?` | Shared element transitions between scenes | ❌ Not used | -| `entryDecorators: List>` | Wraps entry content with additional behavior | ✅ Used — `SaveableStateHolderNavEntryDecorator` + `ViewModelStoreNavEntryDecorator` | - -**APIs we ARE using correctly:** - -| API | Usage | -|---|---| -| `NavDisplay(backStack, entryProvider, modifier)` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` | -| `rememberNavBackStack(SavedStateConfiguration, startKey)` | Backstack persistence | -| `entryProvider { entry { ... } }` | All feature graph registrations | -| `NavigationBackHandler` from `navigationevent-compose` | Used with `ListDetailSceneStrategy` | - -### 2. ViewModel Scoping (`lifecycle-viewmodel-navigation3` `2.11.0-alpha02`) - -**Key finding:** The `ViewModelStoreNavEntryDecorator` is available and provides automatic per-entry ViewModel scoping tied to backstack lifetime. The project passes it as an `entryDecorator` to `NavDisplay` via `MeshtasticNavDisplay` in `core:ui/commonMain`. - -ViewModels obtained via `koinViewModel()` inside `entry` blocks are scoped to the entry's backstack lifetime and automatically cleared when the entry is popped. - -### 3. Material 3 Adaptive — Nav3 Scene Integration - -**Key finding:** The JetBrains `adaptive-navigation3` artifact at `1.3.0-alpha06` includes `ListDetailSceneStrategy` and `SupportingPaneSceneStrategy`. The project uses both via `rememberListDetailSceneStrategy` and `rememberSupportingPaneSceneStrategy` in `MeshtasticNavDisplay`, with draggable pane dividers via `VerticalDragHandle` + `paneExpansionDraggable`. - -This means the project **successfully** uses the M3 Adaptive Scene bridge through `NavDisplay(sceneStrategies = ...)`. Feature entries annotate themselves with `ListDetailSceneStrategy.listPane()`, `.detailPane()`, or `.extraPane()` metadata. - -**When to revisit:** Monitor the JetBrains adaptive fork for `MaterialListDetailSceneStrategy` inclusion. It will likely arrive when the JetBrains fork catches up to the AndroidX `1.3.0-alpha09+` feature set. - -### 4. NavigationSuiteScaffold (`1.11.0-alpha05`) - -**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavigationSuite` now uses `NavigationSuiteScaffold` with `calculateFromAdaptiveInfo()` and custom `NavigationSuiteType` coercion. No further alignment needed. - -## Prioritized Opportunities - -### P0: Add `ViewModelStoreNavEntryDecorator` to NavDisplay (high-value, low-risk) - -**Status:** ✅ Adopted (2026-03-26). Each backstack entry now gets its own `ViewModelStoreOwner` via `rememberViewModelStoreNavEntryDecorator()`. ViewModels obtained via `koinViewModel()` are automatically cleared when their entry is popped. Encapsulated in `MeshtasticNavDisplay` in `core:ui/commonMain`. - -**Impact:** Fixes subtle ViewModel leaks where popped entries retain their ViewModel in the Activity/Window store. Eliminates the need for manual `key = "metrics-$destNum"` ViewModel keying patterns over time. - -### P1: Add default NavDisplay transitions (medium-value, low-risk) - -**Status:** ✅ Adopted (2026-03-26). A shared 350 ms crossfade (`fadeIn` + `fadeOut`) is applied for both forward and pop navigation via `MeshtasticNavDisplay`. This replaces the library's platform defaults (Android: 700 ms fade; Desktop: no animation) with a faster, consistent transition. - -**Impact:** Immediate UX improvement on both Android and Desktop. Desktop now has visible navigation transitions. - -### P2: Adopt `DialogSceneStrategy` for navigation-driven dialogs (medium-value, medium-risk) - -**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavDisplay` includes `DialogSceneStrategy` in `sceneStrategies` before `SinglePaneSceneStrategy`. Feature modules can now use `entry(metadata = DialogSceneStrategy.dialog()) { ... }` to render entries as overlay Dialogs with proper backstack lifecycle and predictive-back support. - -**Impact:** Cleaner dialog lifecycle management available for future dialog routes. Existing dialogs via `AlertHost` are unaffected. - -### Consolidation: `MeshtasticNavDisplay` shared wrapper - -**Status:** ✅ Adopted (2026-03-26). A new `MeshtasticNavDisplay` composable in `core:ui/commonMain` encapsulates the standard `NavDisplay` configuration: -- Entry decorators: `rememberSaveableStateHolderNavEntryDecorator` + `rememberViewModelStoreNavEntryDecorator` -- Scene strategies: `DialogSceneStrategy` + `SinglePaneSceneStrategy` -- Transition specs: 350 ms crossfade (forward + pop) - -Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` now call `MeshtasticNavDisplay` instead of configuring `NavDisplay` directly. The `lifecycle-viewmodel-navigation3` dependency was moved from host modules to `core:ui`. - -### P3: Per-entry transition metadata (low-value until Scene adoption) - -Individual entries can declare custom transitions via `entry(metadata = NavDisplay.transitionSpec { ... })`. This is most useful when different route types should animate differently (e.g., detail screens slide in, settings screens fade). - -**Impact:** Polish improvement. Low priority until default transitions (P1) are established. Now unblocked by P1 adoption. - -### Deferred: Custom Scene strategies - -The `ListDetailSceneStrategy` and `SupportingPaneSceneStrategy` are adopted and working. Consider writing additional custom `SceneStrategy` implementations for specialized layouts (e.g., three-pane "Power User" scenes) as the Navigation 3 Scene API matures. - -## Decision - -~~Adopt **P0** (ViewModel scoping) and **P1** (default transitions) now. Defer P2/P3 and Scene-based multi-pane until the JetBrains adaptive fork adds `MaterialListDetailSceneStrategy`.~~ - -**Updated 2026-03-26:** P0, P1, and P2 adopted and consolidated into `MeshtasticNavDisplay` in `core:ui/commonMain`. P3 (per-entry transitions) is available for incremental adoption by feature modules. Scene-based multi-pane remains deferred. - -## References - -- Navigation 3 source: `navigation3-ui` `1.1.0-beta01` (inspected from Gradle cache) -- [`NavDisplay.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt) (upstream) -- [`SceneStrategy.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/scene/SceneStrategy.kt) (upstream) -- Material 3 Adaptive JetBrains fork: `org.jetbrains.compose.material3.adaptive` `1.3.0-alpha06` diff --git a/docs/decisions/navigation3-parity-2026-03.md b/docs/decisions/navigation3-parity-2026-03.md deleted file mode 100644 index 1d1a8c7ed..000000000 --- a/docs/decisions/navigation3-parity-2026-03.md +++ /dev/null @@ -1,167 +0,0 @@ - - -# Navigation 3 Parity Strategy (Android + Desktop) - -**Date:** 2026-03-11 -**Status:** Implemented (2026-03-21) -**Scope:** `app` and `desktop` navigation structure using shared `core:navigation` routes - -## Context - -Desktop and Android both use Navigation 3 typed routes from `core:navigation`. Previously graph wiring had diverged — desktop used a separate `DesktopDestination` enum with 6 entries (including a top-level Firmware tab) while Android used 5 entries. - -This has been resolved. Both shells now use the shared `TopLevelDestination` enum from `core:navigation/commonMain` with 5 entries (Conversations, Nodes, Map, Settings, Connections). Firmware is an in-flow route on both platforms. - -Both modules still define separate graph-builder files (`app/navigation/*.kt`, `desktop/navigation/*.kt`) with different destination coverage and placeholder behavior, but the **top-level shell structure is unified**. - -## Current-State Findings - -1. **Top-level destinations are unified.** - - Both shells iterate `TopLevelDestination.entries` from `core:navigation/commonMain`. - - Shared icon mapping lives in `core:ui` (`TopLevelDestinationExt.icon`). - - Parity tests exist in both `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). -2. **Feature coverage is unified via `commonMain` feature graphs.** - - The `settingsGraph`, `nodesGraph`, `contactsGraph`, `connectionsGraph`, `firmwareGraph`, and `mapGraph` are now fully shared and exported from their respective feature modules' `commonMain` source sets. - - Desktop acts as a thin shell, delegating directly to these shared graphs. -3. **Saved-state route registration is fully shared.** - - `MeshtasticNavSavedStateConfig` in `core:navigation/commonMain` maintains the unified `SavedStateConfiguration` serializer list. - - Both Android and Desktop reference this shared config when instantiating `rememberNavBackStack`. -4. **Predictive back handling is KMP native.** - - Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`. - -## Alpha04 → Beta01 Changelog Impact Check - -Source reviewed: Navigation 3 `1.1.0-beta01` (JetBrains fork), CMP `1.11.0-beta01`, Lifecycle `2.11.0-alpha02`. - -> **Superseded by:** [`navigation3-api-alignment-2026-03.md`](navigation3-api-alignment-2026-03.md) for the full API surface audit and Scene architecture adoption plan. - -1. **NavDisplay API updated to Scene-based architecture.** - - The `sceneStrategy: SceneStrategy` parameter is deprecated in favor of `sceneStrategies: List>`. - - New `sceneDecoratorStrategies: List>` parameter available. - - New `sharedTransitionScope: SharedTransitionScope?` parameter for shared element transitions. - - Existing shell patterns in `app` and `desktop` remain valid using the default `SinglePaneSceneStrategy`. -2. **Entry-scoped ViewModel lifecycle adopted.** - - Both `app` and `desktop` now use `MeshtasticNavDisplay` (`core:ui/commonMain`), which applies `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` per active backstack. - - ViewModels obtained via `koinViewModel()` inside `entry` blocks are now scoped to the entry's backstack lifetime. -3. **No direct Navigation 3 API breakage.** - - Release is beta (API stabilized). No migration from alpha04 was required for existing usage patterns. -4. **Primary risk is dependency wiring drift, not runtime behavior.** - - JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog. - - Note: The `remember*` composable factory functions from `navigation3-runtime` are not visible in non-KMP Android modules due to Kotlin metadata resolution. Use direct class constructors instead (as done in `app/Main.kt`). -5. **Saved-state and typed-route parity improved.** - - Both hosts share `MeshtasticNavSavedStateConfig` from `core:navigation/commonMain` via `MultiBackstack`, reducing platform drift risk in serializer registration. -6. **Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`).** - -### Actions Taken - -- Renamed all JetBrains-forked lifecycle/nav3 version catalog aliases from `androidx-*` to `jetbrains-*` prefix to make fork provenance unambiguous: - - `jetbrains-lifecycle-runtime`, `jetbrains-lifecycle-runtime-compose`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-viewmodel-navigation3` - - `jetbrains-navigation3-runtime`, `jetbrains-navigation3-ui` -- Documented in the version catalog that `jetbrains-navigation3-runtime` intentionally maps to `navigation3-ui` until a separate runtime artifact is published. -- Migrated `core:data` `commonMain` from `androidx.lifecycle:lifecycle-runtime` (Google) to `org.jetbrains.androidx.lifecycle:lifecycle-runtime` (JetBrains fork) for full consistency. -- Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`). -- Consolidated `app` adaptive dependencies to JetBrains Material 3 Adaptive coordinates (`org.jetbrains.compose.material3.adaptive:*`) so Android and Desktop consume the same adaptive artifact family. The Android-only navigation suite remains on `androidx.compose.material3:material3-adaptive-navigation-suite`. - -### Deferred Follow-ups - -- Add automated validation that desktop serializer registrations stay in sync with shared route keys. - -## Options Evaluated - -### Option A: Reuse `:app` navigation implementation directly in desktop - -**Pros** -- Maximum short-term parity in structure. - -**Cons** -- `:app` graph code is tightly coupled to Android wrappers (`Android*ViewModel`, Android-only screen wrappers, app-specific UI state like scroll-to-top flows). -- Pulling this code into desktop would either fail at compile-time or force additional platform branching in app files. -- Violates clean module boundaries (`desktop` should not depend on Android-specific app glue). - -**Decision:** Not recommended. - -### Option B: Keep fully separate desktop graph and replicate app behavior manually - -**Pros** -- Lowest refactor cost right now. -- Keeps platform customization simple. - -**Cons** -- Drift is guaranteed over time. -- No central policy for intentional vs accidental divergence. -- High maintenance burden for parity-sensitive flows. - -**Decision:** Not recommended as a long-term strategy. - -### Option C (Recommended): Hybrid shared contract + platform graph adapters - -**Pros** -- Preserves platform-specific wiring where needed. -- Reduces drift by moving parity-sensitive definitions to shared contracts. -- Enables explicit, testable exceptions for desktop-only or Android-only behavior. - -**Cons** -- Requires incremental extraction work. -- Needs light governance (parity matrix + tests + docs). - -**Decision:** Recommended. - -## Decision - -Adopt a **hybrid parity model**: - -1. Keep platform graph registration in `app` and `desktop`. -2. Extract parity-sensitive navigation metadata into shared contracts (top-level destination set/order, route ownership map, and allowed platform exceptions). -3. Keep platform-specific destination implementations as adapters around shared route keys. -4. Add route parity tests so drift is detected automatically. - -## Implementation Plan - -### Phase 1 (Immediate): Stop drift on shell structure ✅ - -- ✅ Aligned desktop top-level destination policy with Android (removed Firmware from top-level; kept as in-flow). -- ✅ Both shells now use shared `TopLevelDestination` enum from `core:navigation/commonMain`. -- ✅ Shared icon mapping in `core:ui` (`TopLevelDestinationExt.icon`). -- Parity matrix documented inline: top-level set is Conversations, Nodes, Map, Settings, Connections on both platforms. - -### Phase 2 (Near-term): Extract shared navigation contracts ✅ (partially) - -- ✅ Shared `TopLevelDestination` enum with `fromNavKey()` already serves as the canonical metadata object. -- Both `app` and `desktop` shells iterate `TopLevelDestination.entries` — no separate `DesktopDestination` enum remains. -- Remaining: optional visibility flags by platform, route grouping metadata (lower priority since shells are unified). - -### Phase 3 (Near-term): Add parity checks ✅ (partially) - -- ✅ `NavigationParityTest` in `core:navigation/commonTest` — asserts 5 top-level destinations and `fromNavKey` matching. -- ✅ `DesktopTopLevelDestinationParityTest` in `desktop/test` — asserts desktop routes match Android parity set and firmware is not top-level. -- Remaining: assert every desktop serializer registration corresponds to an actual route; assert every intentional exception is listed. - -### Phase 4 (Mid-term): Reduce app-specific graph coupling - -- Move reusable graph composition helpers out of `:app` where practical (while keeping Android-only wrappers in Android source sets). -- Keep desktop-specific placeholder implementations, but tie them to explicit parity exception entries. - -## Consequences - -- Navigation behavior remains platform-adaptive, but parity expectations become explicit and enforceable. -- Desktop can keep legitimate deviations (map/charts/platform integrations) without silently changing IA. -- New route additions will require touching one shared contract plus platform implementations, making review scope clearer. - -## Source Anchors - -- Shared routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` -- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt` -- Android shell: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt` -- Shared graph registrations: `feature/*/src/commonMain/kotlin/org/meshtastic/feature/*/navigation/` -- Platform graph content: `feature/*/src/{androidMain,jvmMain}/kotlin/org/meshtastic/feature/*/navigation/` -- Desktop shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` -- Desktop graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` - - diff --git a/docs/kmp-status.md b/docs/kmp-status.md index 1f8ce1062..1e6552437 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -1,6 +1,6 @@ # KMP Migration Status -> Last updated: 2026-04-10 +> Last updated: 2026-04-15 Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/). @@ -49,7 +49,7 @@ Modules that share JVM-specific code between Android and desktop now standardize | `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; fully shared `contactsGraph`, `MessageScreen`, `ContactsScreen`, and `MessageListPaged` | | `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection | | `feature:intro` | — | — | Screens remain in `androidMain`; shared ViewModel only | -| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`, and `TracerouteNodeSelection`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` | +| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` | | `feature:firmware` | ✅ | ✅ Fully KMP: Unified OTA, native Secure DFU, USB/UF2, FirmwareRetriever | | `feature:wifi-provision` | ✅ | ✅ KMP WiFi provisioning via BLE (Nymea protocol); shared UI and ViewModel | | `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. | @@ -79,9 +79,7 @@ Working Compose Desktop application with: | Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | **9/10** | 26 modules validated (including feature:wifi-provision); native release installers automated | | DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | - -> See [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md) for the full gap analysis. +| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. SfppHasher, AddressUtils, formatString hex, and MetricFormatter edge cases newly covered. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils | ## Completion Estimates @@ -105,18 +103,20 @@ Based on the latest codebase investigation, the following steps are proposed to | Decision | Status | Details | |---|---|---| -| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | See [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md) | +| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | Both shells use shared `TopLevelDestination` enum and `MeshtasticNavDisplay` from `core:ui/commonMain`; parity tests in `core:navigation/commonTest` | | Hilt → Koin | ✅ Done | See [`decisions/koin-migration.md`](./decisions/koin-migration.md) | | BLE abstraction (Kable) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) | | Firmware KMP migration (pure Secure DFU) | ✅ Done | Native Nordic Secure DFU protocol reimplemented in pure KMP using Kable; desktop is first-class target | -| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta01`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | +| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta02`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | | JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime | -| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained | +| Expect/actual consolidation | ✅ Done | 10+ pairs eliminated (including `formatString`, `CommonUri`, `SfppHasher`); ~20 genuinely platform-specific retained (Parcelable, DateFormatter, Database, Location, Composable UI primitives) | | Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` | | **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. | | **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. | | Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants | | Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioTransport`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | +| URI unification | ✅ Done | `CommonUri` is a `typealias` to `com.eygraber.uri.Uri` (uri-kmp); `MeshtasticUri` wrapper deleted; bridge with `toAndroidUri()`/`toKmpUri()` | +| Utility commonization | ✅ Done | `formatString` → pure Kotlin parser in `commonMain`; `SfppHasher` and `CryptoCodec` → `Okio ByteString.sha256()`; `MetricFormatter` centralizes display strings (temperature, voltage, current, %, humidity, pressure, SNR, RSSI) | ## Navigation Parity Note @@ -127,7 +127,7 @@ Based on the latest codebase investigation, the following steps are proposed to - Firmware remains available as an in-flow route instead of a top-level destination, matching Android information architecture. - Android navigation graphs are decoupled and extracted into their respective feature modules, aligning with the Desktop architecture. - Parity tests exist in `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). -- Remaining parity work is documented in [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md): serializer registration validation and platform exception tracking. +- Remaining parity work: serializer registration validation and platform exception tracking. ## App Module Thinning Status @@ -159,19 +159,17 @@ Remaining to be extracted from `:app` or unified in `commonMain`: | Dependency | Version | Why | |---|---|---| -| Compose Multiplatform | `1.11.0-beta01` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha05` | -| Compose Multiplatform Material 3 | `1.11.0-alpha05` | Material 3 components including `NavigationSuiteScaffold` | -| Koin | `4.2.0` | Nav3 + K2 compiler plugin support | -| JetBrains Lifecycle | `2.11.0-alpha02` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels | -| JetBrains Navigation 3 | `1.1.0-beta01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs | +| Compose Multiplatform | `1.11.0-beta02` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha06` | +| Compose Multiplatform Material 3 | `1.11.0-alpha06` | Material 3 components including `NavigationSuiteScaffold` | +| Koin | `4.2.1` | Nav3 + K2 compiler plugin support | +| JetBrains Lifecycle | `2.11.0-alpha03` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels | +| JetBrains Navigation 3 | `1.1.0-rc01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs | | JetBrains Navigation Event | `1.1.0-alpha01` | KMP `NavigationBackHandler` for predictive back | | JetBrains Material 3 Adaptive | `1.3.0-alpha06` | `ListDetailPaneScaffold`, `ThreePaneScaffold`, Large/XL breakpoints | | Kable BLE | `0.42.0` | Provides fully multiplatform BLE support | **Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features. -> See [`decisions/navigation3-api-alignment-2026-03.md`](./decisions/navigation3-api-alignment-2026-03.md) for the full Navigation 3 API surface audit and Scene architecture adoption plan. - ## References - Roadmap: [`docs/roadmap.md`](./roadmap.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c9445485..8cff42c1f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,8 @@ # Roadmap -> Last updated: 2026-04-10 +> Last updated: 2026-04-15 -Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). For the full gap analysis, see [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md). +Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). ## Architecture Health (Immediate) @@ -18,6 +18,8 @@ These items address structural gaps identified in the March 2026 architecture re | Auto-wire Desktop ViewModels via K2 Compiler (eliminate manual wiring) | Medium | Low | ✅ | | **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ | | **iOS CI gate (compile-only validation)** | High | Medium | ✅ | +| **Commonize utilities** (`formatString`, `SfppHasher`, `CryptoCodec`, `CommonUri`) | High | Medium | ✅ | +| **Centralize metric formatting** (`MetricFormatter`) | Medium | Low | ✅ | ## Active Work diff --git a/docs/testing/baseline_coverage.md b/docs/testing/baseline_coverage.md deleted file mode 100644 index 6445ea9e5..000000000 --- a/docs/testing/baseline_coverage.md +++ /dev/null @@ -1,6 +0,0 @@ -# Baseline Test Coverage Report -**Date:** Wednesday, March 18, 2026 -**Overall Project Coverage:** 8.796% -**App Module Coverage:** 1.6404% - -This baseline was captured using `./gradlew koverLog` at the start of the 'Expand Testing Coverage' track. \ No newline at end of file diff --git a/docs/testing/final_coverage.md b/docs/testing/final_coverage.md deleted file mode 100644 index bc502d704..000000000 --- a/docs/testing/final_coverage.md +++ /dev/null @@ -1,18 +0,0 @@ -# Final Test Coverage Report -**Date:** Wednesday, March 18, 2026 -**Overall Project Coverage:** 10.2591% (Baseline: 8.796%) -**Absolute Increase:** +1.46% - -## Module Highlights -| Module | Coverage | Notes | -| :--- | :--- | :--- | -| `core:domain` | 26.55% | UseCase gap fill complete. | -| `feature:intro` | 30.76% | ViewModel tests enabled. | -| `feature:map` | 33.33% | BaseMapViewModel tests refactored. | -| `feature:node` | 24.70% | Metrics, Detail, Compass, and Filter tests added/refactored. | -| `feature:connections` | 26.49% | ScannerViewModel verified. | -| `feature:messaging` | 18.54% | MessageViewModel verified. | - -This report concludes the 'Expand Testing Coverage' track. -Significant improvements were made in ViewModel testability through interface extraction and Mokkery/Turbine migration. -Foundational logic in `core:network` was strengthened with Kotest property-based tests. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/changelogs/default.txt b/fastlane/metadata/android/fr-FR/changelogs/default.txt index 0553de284..a322da020 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/default.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/default.txt @@ -1 +1 @@ -For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +Pour des notes de version détaillées, veuillez visiter : https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt index b0a3d738c..b6999aadc 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt @@ -60,7 +60,14 @@ class AndroidGetDiscoveredDevicesUseCase( override fun invoke(showMock: Boolean): Flow { val nodeDb = nodeRepository.nodeDBbyNum - val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } } + // Filter out non-Meshtastic peripherals (headphones, cars, watches, etc.). + // BluetoothAdapter.bondedDevices returns every bonded device on the phone, so we + // must restrict the picker to entries whose advertised name matches the + // Meshtastic firmware pattern (see MeshtasticBleConstants.BLE_NAME_PATTERN). + val bondedBleFlow = + bluetoothRepository.state.map { ble -> + ble.bondedDevices.filter { it.getMeshtasticShortName() != null }.map { DeviceListEntry.Ble(it) } + } val processedTcpFlow = combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 8ed5619cd..7e57f2eff 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -20,7 +20,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -29,9 +28,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.model.RadioController @@ -39,6 +36,7 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase @@ -78,7 +76,7 @@ open class ScannerViewModel( scannedBleDevices.value = emptyMap() scanJob = - viewModelScope.launch { + safeLaunch(tag = "startBleScan") { try { bleScanner .scan( @@ -91,8 +89,6 @@ open class ScannerViewModel( scannedBleDevices.update { current -> current + (device.address to device) } } } - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - co.touchlab.kermit.Logger.w(e) { "BLE scan failed" } } finally { isBleScanningState.value = false } @@ -108,7 +104,7 @@ open class ScannerViewModel( private val discoveredDevicesFlow = showMockTransport .flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + .stateInWhileSubscribed(initialValue = null) /** A combined list of bonded and scanned BLE devices for the UI. */ val bleDevicesForUi: StateFlow> = @@ -131,7 +127,7 @@ open class ScannerViewModel( } .flowOn(dispatchers.default) .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + .stateInWhileSubscribed(initialValue = emptyList()) /** UI StateFlow for USB devices. */ val usbDevicesForUi: StateFlow> = @@ -187,11 +183,11 @@ open class ScannerViewModel( fun addRecentAddress(address: String, name: String) { if (!address.startsWith("t")) return - viewModelScope.launch { recentAddressesDataSource.add(RecentAddress(address, name)) } + safeLaunch(tag = "addRecentAddress") { recentAddressesDataSource.add(RecentAddress(address, name)) } } fun removeRecentAddress(address: String) { - viewModelScope.launch { recentAddressesDataSource.remove(address) } + safeLaunch(tag = "removeRecentAddress") { recentAddressesDataSource.remove(address) } } /** @@ -223,7 +219,7 @@ open class ScannerViewModel( } } is DeviceListEntry.Tcp -> { - viewModelScope.launch { + safeLaunch(tag = "onSelectedTcp") { radioPrefs.setDevName(it.name) addRecentAddress(it.fullAddress, it.name) changeDeviceAddress(it.fullAddress) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt index ecdaeb3c3..4249cd625 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt @@ -18,14 +18,15 @@ package org.meshtastic.feature.connections.domain.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.util.safeCatchingAll import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.demo_mode +import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.meshtastic import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.DiscoveredDevices @@ -49,7 +50,7 @@ class CommonGetDiscoveredDevicesUseCase( tcpServices, recentList, -> - val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic") + val defaultName = safeCatchingAll { getStringSuspend(Res.string.meshtastic) }.getOrDefault("Meshtastic") processTcpServices(tcpServices, recentList, defaultName) } @@ -71,7 +72,7 @@ class CommonGetDiscoveredDevicesUseCase( usbList + if (showMock) { val demoModeLabel = - runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode") + safeCatchingAll { getStringSuspend(Res.string.demo_mode) }.getOrDefault("Demo Mode") listOf(DeviceListEntry.Mock(demoModeLabel)) } else { emptyList() diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index 152e880cb..c6962c8c0 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -32,8 +32,8 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) ConnectionsScreen( scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, - onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, + onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onConfigNavigate = { route -> backStack.add(route) }, ) } @@ -42,8 +42,8 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) ConnectionsScreen( scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, - onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, + onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onConfigNavigate = { route -> backStack.add(route) }, ) } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt index 57f06e225..8f5347e01 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import org.jetbrains.compose.resources.stringResource @@ -75,8 +77,11 @@ fun CurrentlyConnectedInfo( while (bleDevice.device.isConnected) { try { rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() } + } catch (_: TimeoutCancellationException) { + Logger.d { "RSSI read timed out" } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - // RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise Logger.d(e) { "Failed to read RSSI ${e.message}" } } delay(RSSI_DELAY.seconds) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index 8499d4e20..14f4dc42b 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -17,13 +17,13 @@ package org.meshtastic.feature.connections.ui.components import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -36,15 +36,20 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_select_device import org.meshtastic.core.resources.add import org.meshtastic.core.resources.bluetooth import org.meshtastic.core.resources.network @@ -75,15 +80,11 @@ fun DeviceListItem( ) { // Throttle the RSSI updates to match the connected device polling rate var displayedRssi by remember { mutableIntStateOf(rssi ?: 0) } - LaunchedEffect(rssi) { - if (displayedRssi == 0) { - displayedRssi = rssi ?: 0 - } - } + val currentRssi by rememberUpdatedState(rssi) LaunchedEffect(Unit) { while (true) { delay(RSSI_UPDATE_RATE_MS) - displayedRssi = rssi ?: 0 + displayedRssi = currentRssi ?: 0 } } @@ -111,11 +112,19 @@ fun DeviceListItem( is DeviceListEntry.Mock -> stringResource(Res.string.add) } + val selectLabel = stringResource(Res.string.action_select_device) + val isSelected = connectionState is ConnectionState.Connected val clickableModifier = if (onDelete != null) { - Modifier.combinedClickable(onClick = onSelect, onLongClick = onDelete) + Modifier.semantics { selected = isSelected } + .combinedClickable( + onClickLabel = selectLabel, + role = Role.RadioButton, + onClick = onSelect, + onLongClick = onDelete, + ) } else { - Modifier.clickable(onClick = onSelect) + Modifier.selectable(selected = isSelected, role = Role.RadioButton, onClick = onSelect) } ListItem( diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index 1647a5af7..3fa26d1cd 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.firmware import android.content.Context import co.touchlab.kermit.Logger +import com.eygraber.uri.toAndroidUri import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.head @@ -32,7 +33,6 @@ import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.ioDispatcher -import org.meshtastic.core.common.util.toPlatformUri import org.meshtastic.core.model.DeviceHardware import java.io.File import java.io.FileOutputStream @@ -188,7 +188,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien if (!tempDir.exists()) tempDir.mkdirs() try { - val platformUri = uri.toPlatformUri() as android.net.Uri + val platformUri = uri.toAndroidUri() val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null ZipInputStream(inputStream).use { zipInput -> var entry = zipInput.nextEntry @@ -225,9 +225,9 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) { file.toLocalFileOrNull()?.takeIf { it.exists() }?.length() - ?: context.contentResolver - .openAssetFileDescriptor(file.uri.toPlatformUri() as android.net.Uri, "r") - ?.use { descriptor -> descriptor.length.takeIf { it >= 0L } } + ?: context.contentResolver.openAssetFileDescriptor(file.uri.toAndroidUri(), "r")?.use { descriptor -> + descriptor.length.takeIf { it >= 0L } + } ?: 0L } @@ -242,16 +242,13 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien if (localFile != null && localFile.exists()) { localFile.readBytes() } else { - context.contentResolver.openInputStream(artifact.uri.toPlatformUri() as android.net.Uri)?.use { - it.readBytes() - } ?: throw IOException("Cannot open artifact: ${artifact.uri}") + context.contentResolver.openInputStream(artifact.uri.toAndroidUri())?.use { it.readBytes() } + ?: throw IOException("Cannot open artifact: ${artifact.uri}") } } override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = withContext(ioDispatcher) { - val inputStream = - context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri) - ?: return@withContext null + val inputStream = context.contentResolver.openInputStream(uri.toAndroidUri()) ?: return@withContext null val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin") tempFile.parentFile?.mkdirs() inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } @@ -282,10 +279,10 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien withContext(ioDispatcher) { val inputStream = source.toLocalFileOrNull()?.inputStream() - ?: context.contentResolver.openInputStream(source.uri.toPlatformUri() as android.net.Uri) + ?: context.contentResolver.openInputStream(source.uri.toAndroidUri()) ?: throw IOException("Cannot open source URI") val outputStream = - context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri) + context.contentResolver.openOutputStream(destinationUri.toAndroidUri()) ?: throw IOException("Cannot open content URI for writing") inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index 64d550a79..1dcb7ba69 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.firmware import co.touchlab.kermit.Logger +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import org.meshtastic.core.database.entity.FirmwareRelease @@ -29,7 +30,11 @@ private const val FIRMWARE_BASE_URL = "https://raw.githubusercontent.com/meshtas /** OTA partition role in .mt.json manifests — the main application firmware. */ private const val OTA_PART_NAME = "app0" -private val manifestJson = Json { ignoreUnknownKeys = true } +@OptIn(ExperimentalSerializationApi::class) +private val manifestJson = Json { + ignoreUnknownKeys = true + exceptionsWithDebugInfo = false +} /** Retrieves firmware files, either by direct download or by extracting from a release asset zip. */ @Single diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index eee6637af..1b5c0c803 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -163,9 +163,7 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView uri?.let { viewModel.startUpdateFromFile(it) } } - val saveFileLauncher = rememberSaveFileLauncher { meshtasticUri -> - viewModel.saveDfuFile(CommonUri.parse(meshtasticUri.uriString)) - } + val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) } val actions = remember(viewModel, onNavigateUp) { 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 b82e26432..f8ff9fcac 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -35,7 +35,9 @@ 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 import org.meshtastic.core.database.entity.FirmwareReleaseType import org.meshtastic.core.datastore.BootloaderWarningDataSource @@ -90,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) @@ -123,9 +126,10 @@ class FirmwareUpdateViewModel( override fun onCleared() { super.onCleared() - // viewModelScope is already cancelled when onCleared() runs, so use a standalone scope - // for fire-and-forget cleanup of temporary firmware files. - kotlinx.coroutines.CoroutineScope(NonCancellable).launch { + // 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) } } @@ -147,7 +151,7 @@ class FirmwareUpdateViewModel( updateJob = viewModelScope.launch { _state.value = FirmwareUpdateState.Checking - runCatching { + safeCatching { val ourNode = nodeRepository.myNodeInfo.value val address = radioPrefs.devAddr.value?.drop(1) if (address == null || ourNode == null) { @@ -200,7 +204,6 @@ class FirmwareUpdateViewModel( } } .onFailure { e -> - if (e is CancellationException) throw e Logger.e(e) { "Error checking for updates" } val unknownError = UiText.Resource(Res.string.firmware_update_unknown_error) _state.value = @@ -390,7 +393,7 @@ private suspend fun cleanupTemporaryFiles( fileHandler: FirmwareFileHandler, tempFirmwareFile: FirmwareArtifact?, ): FirmwareArtifact? { - runCatching { + safeCatching { tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) } fileHandler.cleanupAllTemporaryFiles() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt index 7980ad96a..40c6ad904 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.firmware.navigation import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -27,8 +28,12 @@ import org.meshtastic.feature.firmware.FirmwareUpdateViewModel /** Registers the firmware update screen entries into the Navigation 3 entry provider. */ fun EntryProviderScope.firmwareGraph(backStack: NavBackStack) { - entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } - entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } + entry { + FirmwareScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + } + entry { + FirmwareScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + } } @Composable diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt index 3bdb0f1d7..8035774c4 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -38,6 +37,7 @@ import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC +import org.meshtastic.core.common.util.safeCatching import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -47,7 +47,7 @@ class BleOtaTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) : UnifiedOtaProtocol { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) @@ -78,7 +78,7 @@ class BleOtaTransport( } @Suppress("MagicNumber") - override suspend fun connect(): Result = runCatching { + override suspend fun connect(): Result = safeCatching { Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." } delay(REBOOT_DELAY) @@ -152,7 +152,7 @@ class BleOtaTransport( sizeBytes: Long, sha256Hash: String, onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val command = OtaCommand.StartOta(sizeBytes, sha256Hash) val packetsSent = sendCommand(command) @@ -189,7 +189,7 @@ class BleOtaTransport( data: ByteArray, chunkSize: Int, onProgress: suspend (Float) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val totalBytes = data.size var sentBytes = 0 @@ -215,7 +215,7 @@ class BleOtaTransport( if (nextSentBytes >= totalBytes && isLastPacketOfChunk) { sentBytes = nextSentBytes onProgress(1.0f) - return@runCatching Unit + return@safeCatching Unit } } is OtaResponse.Error -> { diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt index 97fced4c6..fa9966b66 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt @@ -45,7 +45,7 @@ internal fun calculateMacPlusOne(macAddress: String): String { if (parts.size != MAC_PARTS_COUNT) return macAddress val lastByte = parts[MAC_PARTS_COUNT - 1].toIntOrNull(HEX_RADIX) ?: return macAddress val incremented = ((lastByte + 1) and BYTE_MASK).toString(HEX_RADIX).uppercase().padStart(2, '0') - return parts.take(MAC_PARTS_COUNT - 1).joinToString(":") + ":" + incremented + return "${parts.take(MAC_PARTS_COUNT - 1).joinToString(":")}:$incremented" } /** diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 58c09f16a..82e91413d 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository @@ -67,7 +68,7 @@ private const val GATT_RELEASE_DELAY_MS = 1000L * * All platform I/O (file reading, content-resolver imports) is delegated to [FirmwareFileHandler]. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @Single class Esp32OtaUpdateHandler( private val firmwareRetriever: FirmwareRetriever, @@ -76,6 +77,7 @@ class Esp32OtaUpdateHandler( private val nodeRepository: NodeRepository, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { /** Entry point for FirmwareUpdateHandler interface. Routes to BLE (MAC with colons) or WiFi (IP without). */ @@ -102,7 +104,7 @@ class Esp32OtaUpdateHandler( hardware = hardware, updateState = updateState, firmwareUri = firmwareUri, - transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address) }, + transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatchers.default) }, rebootMode = 1, connectionAttempts = 5, ) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt index 3694c4e6a..d21cc15ea 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.common.util.safeCatching /** * WiFi/TCP transport implementation for ESP32 Unified OTA protocol. @@ -54,7 +55,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In /** Connect to the device via TCP using Ktor raw sockets. */ override suspend fun connect(): Result = withContext(ioDispatcher) { - runCatching { + safeCatching { Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" } val selector = SelectorManager(ioDispatcher) @@ -82,7 +83,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In sizeBytes: Long, sha256Hash: String, onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, - ): Result = runCatching { + ): Result = safeCatching { val command = OtaCommand.StartOta(sizeBytes, sha256Hash) sendCommand(command) @@ -116,7 +117,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In chunkSize: Int, onProgress: suspend (Float) -> Unit, ): Result = withContext(ioDispatcher) { - runCatching { + safeCatching { if (!isConnected) { throw OtaProtocolException.TransferFailed("Not connected") } @@ -166,7 +167,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In override suspend fun close() { withContext(ioDispatcher) { - runCatching { + safeCatching { socket?.close() selectorManager?.close() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt index 10a0a5154..43f6804e1 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt @@ -21,7 +21,11 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecodingException -private val json = Json { ignoreUnknownKeys = true } +@OptIn(ExperimentalSerializationApi::class) +private val json = Json { + ignoreUnknownKeys = true + exceptionsWithDebugInfo = false +} /** * Parse pre-extracted zip entries into a [DfuZipPackage]. diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index 3e673461b..a2eb5a7a4 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -28,6 +28,7 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.resources.Res @@ -70,6 +71,7 @@ class SecureDfuHandler( private val radioController: RadioController, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { @Suppress("LongMethod") @@ -108,7 +110,7 @@ class SecureDfuHandler( var transport: SecureDfuTransport? = null var completed = false try { - transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target) + transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) transport.triggerButtonlessDfu().onFailure { e -> Logger.w(e) { "DFU: Buttonless trigger failed ($e) — device may already be in DFU mode" } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index 83d0deecc..42e92c8ac 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel @@ -46,6 +45,7 @@ import org.meshtastic.core.ble.BleDevice import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.feature.firmware.ota.calculateMacPlusOne import org.meshtastic.feature.firmware.ota.scanForBleDevice import kotlin.time.Duration @@ -66,7 +66,7 @@ class SecureDfuTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) private val bleConnection = connectionFactory.create(transportScope, "Secure DFU") @@ -91,7 +91,7 @@ class SecureDfuTransport( * * The caller must have already released the mesh-service BLE connection before calling this. */ - suspend fun triggerButtonlessDfu(): Result = runCatching { + suspend fun triggerButtonlessDfu(): Result = safeCatching { Logger.i { "DFU: Scanning for device $address to trigger buttonless DFU..." } val device = @@ -152,7 +152,7 @@ class SecureDfuTransport( * Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling * notifications on the Control Point. */ - suspend fun connectToDfuMode(): Result = runCatching { + suspend fun connectToDfuMode(): Result = safeCatching { val dfuAddress = calculateMacPlusOne(address) val targetAddresses = setOf(address, dfuAddress) Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." } @@ -210,7 +210,7 @@ class SecureDfuTransport( * PRN is explicitly disabled (set to 0) for the init packet per the Nordic DFU library convention — the init packet * is small (<512 bytes, fits in a single object) and does not benefit from flow control. */ - suspend fun transferInitPacket(initPacket: ByteArray): Result = runCatching { + suspend fun transferInitPacket(initPacket: ByteArray): Result = safeCatching { Logger.i { "DFU: Transferring init packet (${initPacket.size} bytes)..." } setPrn(0) transferObjectWithRetry(DfuObjectType.COMMAND, initPacket, onProgress = null) @@ -231,12 +231,13 @@ class SecureDfuTransport( * @param firmware Raw bytes of the `.bin` file. * @param onProgress Callback receiving progress in [0.0, 1.0]. */ - suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = runCatching { - Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } - setPrn(PRN_INTERVAL) - transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) - Logger.i { "DFU: Firmware transferred and executed." } - } + suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = + safeCatching { + Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } + setPrn(PRN_INTERVAL) + transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) + Logger.i { "DFU: Firmware transferred and executed." } + } // --------------------------------------------------------------------------- // Abort & teardown @@ -250,7 +251,7 @@ class SecureDfuTransport( * accept a fresh DFU session. */ suspend fun abort() { - runCatching { + safeCatching { bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) service.write(controlChar, byteArrayOf(DfuOpcode.ABORT), BleWriteType.WITH_RESPONSE) @@ -262,7 +263,7 @@ class SecureDfuTransport( /** Disconnect from the DFU target and cancel the transport coroutine scope. */ suspend fun close() { - runCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } + safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } transportScope.cancel() } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt index 723fed82f..0a26fd13e 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt @@ -20,9 +20,11 @@ import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository @@ -59,6 +61,12 @@ class DefaultFirmwareUpdateManagerTest { private val bleScanner: BleScanner = mock(MockMode.autofill) private val bleConnectionFactory: BleConnectionFactory = mock(MockMode.autofill) private val firmwareRetriever = FirmwareRetriever(fileHandler) + private val dispatchers = + CoroutineDispatchers( + io = Dispatchers.Unconfined, + main = Dispatchers.Unconfined, + default = Dispatchers.Unconfined, + ) private val secureDfuHandler = SecureDfuHandler( @@ -67,6 +75,7 @@ class DefaultFirmwareUpdateManagerTest { radioController = radioController, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, + dispatchers = dispatchers, ) private val usbUpdateHandler = @@ -84,6 +93,7 @@ class DefaultFirmwareUpdateManagerTest { nodeRepository = nodeRepository, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, + dispatchers = dispatchers, ) private fun createManager(address: String?): DefaultFirmwareUpdateManager { diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index 4c48a1ced..030d84eff 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) @Test diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index 7032ed408..a8eddff83 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) @Test diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt similarity index 63% rename from core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt rename to feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt index 7669a66b0..3ef5c44ef 100644 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt @@ -14,12 +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.feature.firmware -import android.net.Uri +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.meshtastic.core.common.di.ApplicationCoroutineScope -/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */ -fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString) - -/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */ -fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString()) +internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : + ApplicationCoroutineScope, + CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index acb1545bd..23a0d03ab 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { firmwareUpdateManager, usbManager, fileHandler, + TestApplicationCoroutineScope(testDispatcher), ) // ----------------------------------------------------------------------- diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index a1a31dbf4..294d84e4c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -17,14 +17,12 @@ package org.meshtastic.feature.map import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds @@ -41,6 +39,7 @@ import org.meshtastic.core.resources.eight_hours import org.meshtastic.core.resources.one_day import org.meshtastic.core.resources.one_hour import org.meshtastic.core.resources.two_days +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint @@ -147,7 +146,8 @@ open class BaseMapViewModel( fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) - fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) } + fun deleteWaypoint(id: Int) = + safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) } fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) @@ -159,7 +159,7 @@ open class BaseMapViewModel( } private fun sendDataPacket(p: DataPacket) { - viewModelScope.launch(ioDispatcher) { radioController.sendMessage(p) } + safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) } } fun generatePacketId(): Int = radioController.getPacketId() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index 2c0b5e7b8..8d2af9c4d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -26,8 +26,8 @@ fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current mapScreen( - { backStack.add(NodesRoute.NodeDetail(it)) }, // onClickNodeChip - { backStack.add(NodesRoute.NodeDetail(it)) }, // navigateToNodeDetails + { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip + { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails args.waypointId, ) } diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index 80eed61c5..f2887d98a 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -42,7 +42,6 @@ kotlin { implementation(projects.core.ui) implementation(libs.jetbrains.navigation3.ui) - implementation(libs.jetbrains.navigationevent.compose) implementation(libs.androidx.paging.common) implementation(libs.androidx.paging.compose) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index 9cd435f82..9a742a4ea 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -44,8 +43,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType @@ -452,23 +450,12 @@ private fun UpdateUnreadCountPaged( onUnreadChange: (Long, Long) -> Unit, ) { val currentOnUnreadChange by rememberUpdatedState(onUnreadChange) - val lifecycleOwner = LocalLifecycleOwner.current - var isResumed by remember { - mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) - } + var isResumed by remember { mutableStateOf(false) } // Track lifecycle state changes - DisposableEffect(lifecycleOwner) { - val observer = - androidx.lifecycle.LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> isResumed = true - Lifecycle.Event.ON_PAUSE -> isResumed = false - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + LifecycleResumeEffect(Unit) { + isResumed = true + onPauseOrDispose { isResumed = false } } // Track remote message count to restart effect when remote messages change diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 7c57b46af..4d3e5679d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ContactSettings @@ -49,6 +48,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.usecase.SendMessageUseCase +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet @@ -157,7 +157,7 @@ class MessageViewModel( } fun setTitle(title: String) { - viewModelScope.launch { _title.value = title } + _title.value = title } fun getMessagesFromPaged(contactKey: String): Flow> { @@ -190,7 +190,9 @@ class MessageViewModel( } fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") { + packetRepository.setContactFilteringDisabled(contactKey, disabled) + } } fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) @@ -211,21 +213,21 @@ class MessageViewModel( * @param replyId The ID of the message this is a reply to, if any. */ fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) { - viewModelScope.launch { sendMessageUseCase.invoke(str, contactKey, replyId) } + safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) } } - fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch { + fun sendReaction(emoji: String, replyId: Int, contactKey: String) = safeLaunch(tag = "sendReaction") { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) } fun deleteMessages(uuidList: List) = - viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) } + safeLaunch(context = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) } fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) = - viewModelScope.launch(ioDispatcher) { + safeLaunch(context = ioDispatcher, tag = "clearUnreadCount") { val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE if (lastReadTimestamp <= existingTimestamp) { - return@launch + return@safeLaunch } packetRepository.clearUnreadCount(contact, lastReadTimestamp) packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp) diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt index 53d023d08..6451b8885 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt @@ -17,12 +17,11 @@ package org.meshtastic.feature.messaging import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.repository.QuickChatActionRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed @KoinViewModel @@ -31,7 +30,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) fun updateActionPositions(actions: List) { - viewModelScope.launch(ioDispatcher) { + safeLaunch(context = ioDispatcher, tag = "updateActionPositions") { for (position in actions.indices) { quickChatActionRepository.setItemPosition(actions[position].uuid, position) } @@ -39,8 +38,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR } fun addQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) } + safeLaunch(context = ioDispatcher, tag = "addQuickChatAction") { quickChatActionRepository.upsert(action) } fun deleteQuickChatAction(action: QuickChatAction) = - viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) } + safeLaunch(context = ioDispatcher, tag = "deleteQuickChatAction") { quickChatActionRepository.delete(action) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index 380b913a5..5ffb5ea1d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -36,12 +36,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_copy_message +import org.meshtastic.core.resources.action_delete_message +import org.meshtastic.core.resources.action_react_with_emoji +import org.meshtastic.core.resources.action_select_message +import org.meshtastic.core.resources.action_send_reply +import org.meshtastic.core.resources.action_show_message_status import org.meshtastic.core.resources.copy import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.device_metrics_label_value @@ -56,6 +62,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Reply import org.meshtastic.core.ui.icon.SelectAll +@Suppress("LongMethod") @Composable fun MessageActionsContent( quickEmojis: List, @@ -84,20 +91,35 @@ fun MessageActionsContent( Text(stringResource(Res.string.device_metrics_label_value, title, statusText.orEmpty())) }, leadingContent = { MessageStatusIcon(status = status) }, - modifier = Modifier.clickable(onClick = onStatus), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_show_message_status), + role = Role.Button, + onClick = onStatus, + ), ) } ListItem( headlineContent = { Text(stringResource(Res.string.reply)) }, leadingContent = { Icon(MeshtasticIcons.Reply, contentDescription = stringResource(Res.string.reply)) }, - modifier = Modifier.clickable(onClick = onReply), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_send_reply), + role = Role.Button, + onClick = onReply, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.copy)) }, leadingContent = { Icon(MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy)) }, - modifier = Modifier.clickable(onClick = onCopy), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_copy_message), + role = Role.Button, + onClick = onCopy, + ), ) ListItem( @@ -105,13 +127,23 @@ fun MessageActionsContent( leadingContent = { Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select)) }, - modifier = Modifier.clickable(onClick = onSelect), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_select_message), + role = Role.Button, + onClick = onSelect, + ), ) ListItem( headlineContent = { Text(stringResource(Res.string.delete)) }, leadingContent = { Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete)) }, - modifier = Modifier.clickable(onClick = onDelete), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_delete_message), + role = Role.Button, + onClick = onDelete, + ), ) } } @@ -131,10 +163,15 @@ private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit, Modifier.size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onReact(emoji) }, + .clickable( + onClickLabel = stringResource(Res.string.action_react_with_emoji), + role = Role.Button, + ) { + onReact(emoji) + }, contentAlignment = Alignment.Center, ) { - Text(text = emoji, fontSize = 20.sp) + Text(text = emoji, style = MaterialTheme.typography.titleMedium) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 586b91dd6..7d8747eb8 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -29,14 +29,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -47,8 +45,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -72,6 +73,8 @@ import org.meshtastic.core.ui.emoji.EmojiPickerDialog import org.meshtastic.core.ui.icon.FormatQuote import org.meshtastic.core.ui.icon.HopCount import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.theme.ContrastLevel +import org.meshtastic.core.ui.theme.LocalContrastLevel import org.meshtastic.core.ui.theme.MessageItemColors import org.meshtastic.core.ui.util.createClipEntry @@ -175,7 +178,9 @@ fun MessageItem( } val containsBel = message.text.contains('\u0007') + val contrastLevel = LocalContrastLevel.current + val nodeColor = Color(if (message.fromLocal) ourNode.colors.second else node.colors.second) val alpha = if (message.filtered) { FILTERED_ALPHA @@ -184,15 +189,31 @@ fun MessageItem( } else { NORMAL_ALPHA } + val containerColor = - if (message.fromLocal) { - Color(ourNode.colors.second).copy(alpha = alpha) - } else { - Color(node.colors.second).copy(alpha = alpha) + when (contrastLevel) { + ContrastLevel.HIGH -> + when { + message.filtered -> MaterialTheme.colorScheme.surfaceContainerLow + inSelectionMode && selected -> MaterialTheme.colorScheme.surfaceContainerHighest + inSelectionMode && !selected -> MaterialTheme.colorScheme.surfaceContainerLow + else -> MaterialTheme.colorScheme.surfaceContainerHigh + } + ContrastLevel.MEDIUM -> nodeColor.copy(alpha = (alpha + 0.2f).coerceAtMost(1f)) + ContrastLevel.STANDARD -> nodeColor.copy(alpha = alpha) + } + val contentColor = + when (contrastLevel) { + ContrastLevel.HIGH, + ContrastLevel.MEDIUM, + -> MaterialTheme.colorScheme.onSurface + ContrastLevel.STANDARD -> Color(if (message.fromLocal) ourNode.colors.first else node.colors.first) + } + val metadataStyle = + when (contrastLevel) { + ContrastLevel.HIGH -> MaterialTheme.typography.bodySmall + else -> MaterialTheme.typography.labelSmall } - val cardColors = - CardDefaults.cardColors() - .copy(containerColor = containerColor, contentColor = contentColorFor(containerColor)) val messageShape = getMessageBubbleShape( cornerRadius = 8.dp, @@ -206,7 +227,12 @@ fun MessageItem( if (containsBel) { Modifier.border(2.dp, color = MessageItemColors.Red, shape = messageShape) } else { - Modifier + when (contrastLevel) { + ContrastLevel.HIGH -> Modifier.border(2.dp, color = nodeColor, shape = messageShape) + ContrastLevel.MEDIUM -> + Modifier.border(1.dp, color = nodeColor.copy(alpha = 0.6f), shape = messageShape) + ContrastLevel.STANDARD -> Modifier + } }, ) val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name @@ -244,9 +270,12 @@ fun MessageItem( onDoubleClick = onDoubleClick, ) .then(messageModifier) - .semantics(mergeDescendants = true) { contentDescription = messageA11yText }, + .semantics(mergeDescendants = true) { + contentDescription = messageA11yText + role = Role.Button + }, color = containerColor, - contentColor = contentColorFor(containerColor), + contentColor = contentColor, shape = messageShape, ) { Column(modifier = Modifier.width(IntrinsicSize.Max)) { @@ -254,16 +283,11 @@ fun MessageItem( modifier = Modifier.fillMaxWidth(), message = message, ourNode = ourNode, - hasSamePrev = hasSamePrev, onNavigateToOriginalMessage = onNavigateToOriginalMessage, ) Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { - AutoLinkText( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = cardColors.contentColor, - ) + AutoLinkText(text = message.text, style = MaterialTheme.typography.bodyMedium, color = contentColor) Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { if (!message.fromLocal) { @@ -281,7 +305,10 @@ fun MessageItem( imageVector = MeshtasticIcons.HopCount, contentDescription = null, modifier = Modifier.size(14.dp), - tint = cardColors.contentColor.copy(alpha = 0.7f), + tint = + contentColor.copy( + alpha = if (contrastLevel == ContrastLevel.HIGH) 1f else 0.7f, + ), ) Text( text = @@ -290,7 +317,7 @@ fun MessageItem( } else { "?" }, - style = MaterialTheme.typography.labelSmall, + style = metadataStyle, ) } } @@ -306,8 +333,13 @@ fun MessageItem( if (message.filtered) { Text( text = stringResource(Res.string.filter_message_label), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = metadataStyle, + color = + if (contrastLevel == ContrastLevel.HIGH) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, modifier = Modifier.padding(start = 8.dp, end = 4.dp), ) } @@ -318,11 +350,7 @@ fun MessageItem( ) } Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier.padding(start = 16.dp), - text = message.time, - style = MaterialTheme.typography.labelSmall, - ) + Text(modifier = Modifier.padding(start = 16.dp), text = message.time, style = metadataStyle) } } } @@ -356,30 +384,33 @@ private enum class ActiveSheet { private fun OriginalMessageSnippet( message: Message, ourNode: Node, - hasSamePrev: Boolean, onNavigateToOriginalMessage: (Int) -> Unit, modifier: Modifier = Modifier, ) { val originalMessage = message.originalMessage if (originalMessage != null && originalMessage.packetId != 0) { val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node - val cardColors = - CardDefaults.cardColors() - .copy( - containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.8f), - contentColor = Color(originalMessageNode.colors.first), - ) + val contrastLevel = LocalContrastLevel.current + val replyContainerColor = + when (contrastLevel) { + ContrastLevel.HIGH -> MaterialTheme.colorScheme.surfaceContainer + else -> Color(originalMessageNode.colors.second).copy(alpha = 0.8f) + } + val replyContentColor = + when (contrastLevel) { + ContrastLevel.HIGH, + ContrastLevel.MEDIUM, + -> MaterialTheme.colorScheme.onSurface + ContrastLevel.STANDARD -> Color(originalMessageNode.colors.first) + } + // Rectangle shape — the outer message bubble's Surface clips to its + // rounded corners, so the reply header inherits the correct top radii + // automatically and stays square on the bottom where body text follows. Surface( modifier = modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) }, - contentColor = cardColors.contentColor, - color = cardColors.containerColor, - shape = - getMessageBubbleShape( - cornerRadius = 16.dp, - isSender = originalMessage.fromLocal, - hasSamePrev = hasSamePrev, - hasSameNext = true, // always square off original message bottom - ), + contentColor = replyContentColor, + color = replyContainerColor, + shape = RectangleShape, ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt index 501a3f7dc..7b361d497 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt @@ -24,11 +24,13 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.message_delivery_status import org.meshtastic.core.ui.icon.Acknowledged +import org.meshtastic.core.ui.icon.AddLink +import org.meshtastic.core.ui.icon.CloudUpload +import org.meshtastic.core.ui.icon.LinkIcon import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MessageEnroute import org.meshtastic.core.ui.icon.MessageError import org.meshtastic.core.ui.icon.MqttDelivered -import org.meshtastic.core.ui.icon.MqttSyncing import org.meshtastic.core.ui.icon.Warning @Composable @@ -36,10 +38,10 @@ fun MessageStatusIcon(status: MessageStatus, modifier: Modifier = Modifier) { val icon = when (status) { MessageStatus.RECEIVED -> MeshtasticIcons.Acknowledged - MessageStatus.QUEUED -> MeshtasticIcons.MqttSyncing + MessageStatus.QUEUED -> MeshtasticIcons.CloudUpload MessageStatus.DELIVERED -> MeshtasticIcons.MqttDelivered - MessageStatus.SFPP_ROUTING -> MeshtasticIcons.MqttSyncing - MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.MqttDelivered + MessageStatus.SFPP_ROUTING -> MeshtasticIcons.AddLink + MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.LinkIcon MessageStatus.ENROUTE -> MeshtasticIcons.MessageEnroute MessageStatus.ERROR -> MeshtasticIcons.MessageError else -> MeshtasticIcons.Warning diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 6545083bb..9b8267793 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -123,7 +123,6 @@ internal fun ReactionItem( text = emojiCount.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, - fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -144,7 +143,7 @@ internal fun ReactionRow( AnimatedVisibility(emojiGroups.isNotEmpty(), modifier = modifier) { LazyRow(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - items(emojiGroups.entries.toList()) { entry -> + items(emojiGroups.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } @@ -238,7 +237,7 @@ internal fun ReactionDialog( } LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - items(groupedEmojis.entries.toList()) { entry -> + items(groupedEmojis.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } @@ -248,7 +247,13 @@ internal fun ReactionDialog( text = "$emoji${reactions.size}", modifier = Modifier.clip(CircleShape) - .background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent) + .background( + if (selectedEmoji == emoji) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + Color.Transparent + }, + ) .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier) .padding(8.dp) .clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji }, @@ -260,7 +265,7 @@ internal fun ReactionDialog( HorizontalDivider(Modifier.padding(vertical = 8.dp)) LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(filteredReactions) { reaction -> + items(filteredReactions, key = { reaction -> "${reaction.user.id}:${reaction.emoji}" }) { reaction -> Column(modifier = Modifier.padding(horizontal = 8.dp)) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt similarity index 100% rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index 0f347f980..62b57d3a8 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -61,9 +62,10 @@ fun EntryProviderScope.contactsGraph( contactKey = contactKey, message = args.message, viewModel = messageViewModel, - navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, - navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoute.QuickChat) }, - onNavigateBack = { backStack.removeLastOrNull() }, + navigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + navigateToQuickChatOptions = + dropUnlessResumed { backStack.add(org.meshtastic.core.navigation.ContactsRoute.QuickChat) }, + onNavigateBack = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -73,13 +75,13 @@ fun EntryProviderScope.contactsGraph( ShareScreen( viewModel = viewModel, onConfirm = { contactKey -> backStack.replaceLast(ContactsRoute.Messages(contactKey, message)) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } entry(metadata = { ListDetailSceneStrategy.extraPane() }) { val viewModel = koinViewModel() - QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) + QuickChatScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index d8f7eeae0..1607ffa5d 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.navigation.ChannelsRoute import org.meshtastic.core.navigation.ContactsRoute import org.meshtastic.core.navigation.NodesRoute @@ -35,7 +35,7 @@ fun AdaptiveContactsScreen( scrollToTopEvents: Flow, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, - onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, + onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, ) { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index ac6232ac2..7abaf6db6 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -61,7 +62,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState @@ -117,7 +118,7 @@ fun ContactsScreen( onNavigateToShare: () -> Unit, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, - onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, + onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, viewModel: ContactsViewModel, @@ -131,8 +132,8 @@ fun ContactsScreen( val scope = rememberCoroutineScope() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - var showMuteDialog by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf(false) } + var showMuteDialog by rememberSaveable { mutableStateOf(false) } + var showDeleteDialog by rememberSaveable { mutableStateOf(false) } // State for managing selected contacts val selectedContactKeys = remember { mutableStateListOf() } @@ -255,7 +256,7 @@ fun ContactsScreen( MeshtasticImportFAB( sharedContact = sharedContactRequested, onImport = { uriString -> - onHandleDeepLink(MeshtasticUri(uriString)) { + onHandleDeepLink(CommonUri.parse(uriString)) { scope.launch { showToast(Res.string.channel_invalid) } } }, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index 865242cfb..f8aa46032 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Contact @@ -37,6 +36,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet import kotlin.collections.map as collectionsMap @@ -188,17 +188,20 @@ class ContactsViewModel( fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) fun deleteContacts(contacts: List) = - viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) } + safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) } - fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() } + fun markAllAsRead() = + safeLaunch(context = ioDispatcher, tag = "markAllAsRead") { packetRepository.clearAllUnreadCounts() } fun setMuteUntil(contacts: List, until: Long) = - viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) } + safeLaunch(context = ioDispatcher, tag = "setMuteUntil") { packetRepository.setMuteUntil(contacts, until) } fun getContactSettings() = packetRepository.getContactSettings() fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") { + packetRepository.setContactFilteringDisabled(contactKey, disabled) + } } /** diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 68f7817aa..cf45cb1ec 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -19,7 +19,7 @@ package org.meshtastic.feature.messaging.component import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index b7c5f35bd..699021fbc 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.node.compass import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,7 +25,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.formatString @@ -37,6 +35,7 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.ui.component.precisionBitsToMeters +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.proto.Config import org.meshtastic.proto.Position import kotlin.math.abs @@ -92,13 +91,17 @@ class CompassViewModel( updatesJob?.cancel() - updatesJob = viewModelScope.launch { - combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { heading, location -> - buildState(heading, location) + updatesJob = + safeLaunch(tag = "compassUpdates") { + combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { + heading, + location, + -> + buildState(heading, location) + } + .flowOn(dispatchers.default) + .collect { _uiState.value = it } } - .flowOn(dispatchers.default) - .collect { _uiState.value = it } - } } fun stop() { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt index aa44a6b7e..067d9cf40 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.resources.ic_radioactive import org.meshtastic.core.resources.ic_soil_moisture import org.meshtastic.core.resources.ic_soil_temperature import org.meshtastic.core.resources.lux +import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.pressure import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.soil_moisture @@ -222,6 +223,18 @@ internal fun EnvironmentMetrics( ), ) } + // 1-Wire temperature sensors (up to 8 channels) + one_wire_temperature + .filterNot { it.isNaN() } + .forEachIndexed { idx, temp -> + add( + DrawableMetricInfo( + label = Res.string.one_wire_temperature, + value = "${idx + 1}: ${temp.toTempString(isFahrenheit)}", + icon = Res.drawable.ic_soil_temperature, + ), + ) + } } } FlowRow( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 51f131bda..036fd3404 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.Base64Factory -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.formatUptime @@ -263,7 +263,7 @@ private fun SignalRow(node: Node) { if (node.snr != Float.MAX_VALUE) { InfoItem( label = stringResource(Res.string.snr), - value = formatString("%.1f dB", node.snr), + value = MetricFormatter.snr(node.snr), icon = MeshtasticIcons.Snr, modifier = Modifier.weight(1f), ) @@ -273,7 +273,7 @@ private fun SignalRow(node: Node) { if (node.rssi != Int.MAX_VALUE) { InfoItem( label = stringResource(Res.string.rssi), - value = formatString("%d dBm", node.rssi), + value = MetricFormatter.rssi(node.rssi), icon = MeshtasticIcons.Rssi, modifier = Modifier.weight(1f), ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt index cfac18158..0bc022c34 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign @@ -56,6 +57,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.clear import org.meshtastic.core.resources.desc_node_filter_clear import org.meshtastic.core.resources.node_filter_exclude_infrastructure import org.meshtastic.core.resources.node_filter_exclude_mqtt @@ -178,14 +180,19 @@ private fun NodeFilterTextField(filterText: String, onTextChange: (String) -> Un onValueChange = onTextChange, trailingIcon = { if (filterText.isNotEmpty() || isFocused) { + val clearLabel = stringResource(Res.string.clear) Icon( MeshtasticIcons.Close, contentDescription = stringResource(Res.string.desc_node_filter_clear), modifier = - Modifier.clickable { - onTextChange("") - focusManager.clearFocus() - }, + Modifier.clickable( + onClickLabel = clearLabel, + role = Role.Button, + onClick = { + onTextChange("") + focusManager.clearFocus() + }, + ), ) } }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 514be15e7..22f4422ad 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -46,11 +46,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.isUnmessageableRole -import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_utilization @@ -106,6 +106,7 @@ fun NodeItem( onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, connectionState: ConnectionState, + deviceType: DeviceType? = null, isActive: Boolean = false, ) { val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } @@ -166,6 +167,7 @@ fun NodeItem( isMuted = isMuted, isUnmessageable = unmessageable, connectionState = connectionState, + deviceType = deviceType, contentColor = contentColor, ) @@ -257,14 +259,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col icon = MeshtasticIcons.ChannelUtilization, contentDescription = stringResource(Res.string.channel_utilization), label = stringResource(Res.string.channel_utilization), - text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization), + text = MetricFormatter.percent(thatNode.deviceMetrics.channel_utilization ?: 0f), contentColor = contentColor, ) IconInfo( icon = MeshtasticIcons.AirUtilization, contentDescription = stringResource(Res.string.air_utilization), label = stringResource(Res.string.air_utilization), - text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx), + text = MetricFormatter.percent(thatNode.deviceMetrics.air_util_tx ?: 0f), contentColor = contentColor, ) } @@ -317,31 +319,24 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C } if ((env.temperature ?: 0f) != 0f) { - val temp = - if (tempInFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f)) - } else { - formatString("%.1f°C", env.temperature ?: 0f) - } + val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit) items.add { TemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.relative_humidity ?: 0f) != 0f) { items.add { - HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor) + HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor) } } if ((env.barometric_pressure ?: 0f) != 0f) { items.add { - PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor) + PressureInfo( + pressure = MetricFormatter.pressure(env.barometric_pressure ?: 0f), + contentColor = contentColor, + ) } } if ((env.soil_temperature ?: 0f) != 0f) { - val temp = - if (tempInFahrenheit) { - formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f)) - } else { - formatString("%.1f°C", env.soil_temperature ?: 0f) - } + val temp = MetricFormatter.temperature(env.soil_temperature ?: 0f, tempInFahrenheit) items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) { @@ -350,7 +345,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.voltage ?: 0f) != 0f) { items.add { PowerInfo( - value = formatString("%.2fV", env.voltage ?: 0f), + value = MetricFormatter.voltage(env.voltage ?: 0f), label = stringResource(Res.string.voltage), contentColor = contentColor, ) @@ -359,7 +354,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.current ?: 0f) != 0f) { items.add { PowerInfo( - value = formatString("%.1fmA", env.current ?: 0f), + value = MetricFormatter.current(env.current ?: 0f), label = stringResource(Res.string.current), contentColor = contentColor, ) @@ -400,6 +395,7 @@ private fun NodeItemHeader( isMuted: Boolean, isUnmessageable: Boolean, connectionState: ConnectionState, + deviceType: DeviceType?, contentColor: Color, ) { Row( @@ -445,6 +441,7 @@ private fun NodeItemHeader( isMuted = isMuted, isUnmessageable = isUnmessageable, connectionState = connectionState, + deviceType = deviceType, contentColor = contentColor, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt index 007c12c96..1bbafad6a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connecting @@ -46,17 +47,11 @@ import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.mute_always import org.meshtastic.core.resources.unmessageable import org.meshtastic.core.resources.unmonitored_or_infrastructure -import org.meshtastic.core.ui.icon.DeviceSleep -import org.meshtastic.core.ui.icon.Disconnected +import org.meshtastic.core.ui.component.ConnectionsNavIcon import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.MqttDelivered -import org.meshtastic.core.ui.icon.MqttSyncing import org.meshtastic.core.ui.icon.Unmessageable import org.meshtastic.core.ui.icon.VolumeOff -import org.meshtastic.core.ui.theme.StatusColors.StatusGreen -import org.meshtastic.core.ui.theme.StatusColors.StatusOrange -import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @OptIn(ExperimentalMaterial3Api::class) @@ -68,11 +63,12 @@ fun NodeStatusIcons( isMuted: Boolean, connectionState: ConnectionState, modifier: Modifier = Modifier, + deviceType: DeviceType? = null, contentColor: Color = LocalContentColor.current, ) { Row(modifier = modifier.padding(4.dp)) { if (isThisNode) { - ThisNodeStatusBadge(connectionState) + ThisNodeStatusBadge(connectionState = connectionState, deviceType = deviceType) } if (isUnmessageable) { @@ -104,7 +100,7 @@ fun NodeStatusIcons( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ThisNodeStatusBadge(connectionState: ConnectionState) { +private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: DeviceType?) { TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { @@ -123,55 +119,10 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState) { }, state = rememberTooltipState(), ) { - when (connectionState) { - ConnectionState.Connected -> ConnectedStatusIcon() - ConnectionState.Connecting -> ConnectingStatusIcon() - ConnectionState.Disconnected -> DisconnectedStatusIcon() - ConnectionState.DeviceSleep -> DeviceSleepStatusIcon() - } + ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType, modifier = Modifier.size(24.dp)) } } -@Composable -private fun ConnectedStatusIcon() { - Icon( - imageVector = MeshtasticIcons.MqttDelivered, - contentDescription = stringResource(Res.string.connected), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusGreen, - ) -} - -@Composable -private fun ConnectingStatusIcon() { - Icon( - imageVector = MeshtasticIcons.MqttSyncing, - contentDescription = stringResource(Res.string.connecting), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusOrange, - ) -} - -@Composable -private fun DisconnectedStatusIcon() { - Icon( - imageVector = MeshtasticIcons.Disconnected, - contentDescription = stringResource(Res.string.disconnected), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusRed, - ) -} - -@Composable -private fun DeviceSleepStatusIcon() { - Icon( - imageVector = MeshtasticIcons.DeviceSleep, - contentDescription = stringResource(Res.string.device_sleeping), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.StatusYellow, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StatusBadge( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt index 9ce025604..559582417 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt @@ -43,10 +43,7 @@ internal fun handleNodeAction( val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode) navigateToMessages(route) } - is NodeMenuAction.Remove -> { - viewModel.handleNodeMenuAction(menuAction) - onNavigateUp() - } + is NodeMenuAction.Remove -> viewModel.handleNodeMenuAction(menuAction, onNavigateUp) else -> viewModel.handleNodeMenuAction(menuAction) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 45b3cc2b8..e891d8ae0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -21,13 +21,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.DataPacket @@ -35,6 +33,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.UiText +import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.metrics.EnvironmentMetricsState @@ -81,7 +80,7 @@ class NodeDetailViewModel( if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState()) getNodeDetailsUseCase(nodeId) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState()) + .stateInWhileSubscribed(initialValue = NodeDetailUiState()) fun start(nodeId: Int) { if (manualNodeId.value != nodeId) { @@ -90,9 +89,10 @@ class NodeDetailViewModel( } /** Dispatches high-level node management actions like removal, muting, or favoriting. */ - fun handleNodeMenuAction(action: NodeMenuAction) { + fun handleNodeMenuAction(action: NodeMenuAction, onAfterRemove: () -> Unit = {}) { when (action) { - is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node) + is NodeMenuAction.Remove -> + nodeManagementActions.requestRemoveNode(viewModelScope, action.node, onAfterRemove) is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node) is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node) is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index 436954201..9c021e666 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -50,11 +50,14 @@ constructor( private val radioController: RadioController, private val alertManager: AlertManager, ) { - open fun requestRemoveNode(scope: CoroutineScope, node: Node) { + open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) { alertManager.showAlert( titleRes = Res.string.remove, messageRes = Res.string.remove_node_text, - onConfirm = { removeNode(scope, node.num) }, + onConfirm = { + removeNode(scope, node.num) + onAfterRemove() + }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 9c2c208f4..2e8093ad8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -72,7 +72,7 @@ fun NodeListScreen( onNavigateToChannels: () -> Unit = {}, scrollToTopEvents: Flow? = null, activeNodeId: Int? = null, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val showToast = org.meshtastic.core.ui.util.rememberShowToastResource() val scope = rememberCoroutineScope() @@ -97,6 +97,7 @@ fun NodeListScreen( } val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val deviceType by viewModel.deviceType.collectAsStateWithLifecycle() val isScrollInProgress by remember { derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) } @@ -124,7 +125,7 @@ fun NodeListScreen( alignment = androidx.compose.ui.Alignment.BottomEnd, ), onImport = { uriString -> - onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) { + onHandleDeepLink(org.meshtastic.core.common.util.CommonUri.parse(uriString)) { scope.launch { showToast(Res.string.channel_invalid) } } }, @@ -187,6 +188,7 @@ fun NodeListScreen( onClick = { navigateToNodeDetails(node.num) }, onLongClick = longClick, connectionState = connectionState, + deviceType = deviceType, isActive = isActive, ) val isThisNode = remember(node) { ourNode?.num == node.num } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index df65a3477..172a296eb 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -23,13 +23,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeManagementActions @@ -45,6 +48,7 @@ class NodeListViewModel( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val radioController: RadioController, + private val radioInterfaceService: RadioInterfaceService, val nodeManagementActions: NodeManagementActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, val nodeFilterPreferences: NodeFilterPreferences, @@ -58,6 +62,11 @@ class NodeListViewModel( val connectionState = serviceRepository.connectionState + val deviceType: StateFlow = + radioInterfaceService.currentDeviceAddressFlow + .map { address -> address?.let { DeviceType.fromAddress(it) } } + .stateInWhileSubscribed(initialValue = null) + private val nodeSortOption = nodeFilterPreferences.nodeSortOption private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index 8f65bf6d8..88f4d1d6d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.IconToggleButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,6 +80,9 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.icon.Save +/** Minimum x-step (in seconds) to prevent the default GCD from producing a value of 1 with irregular timestamps. */ +private const val MIN_X_STEP_SECONDS = 60.0 + /** * A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point * selection synchronization. @@ -100,43 +104,50 @@ fun GenericMetricChart( onPointSelected: ((Double) -> Unit)? = null, vicoScrollState: VicoScrollState = rememberVicoScrollState(), ) { - // Hoist zoom state above rememberCartesianChart so that the variable slot count - // from the vararg layers spread does not shift this remember call during recomposition - // (toggling legend chips changes the layer count, which corrupts the slot table). - val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content) + // Key on layer count so Compose rebuilds the entire subtree when legend chip toggles + // add/remove layers. rememberCartesianChart uses vararg internally, so changing the + // argument count without a key corrupts the slot table. + key(layers.size) { + val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content) - val markerVisibilityListener = - remember(onPointSelected) { - object : CartesianMarkerVisibilityListener { - override fun onShown(marker: CartesianMarker, targets: List) { - targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } - } + val markerVisibilityListener = + remember(onPointSelected) { + object : CartesianMarkerVisibilityListener { + override fun onShown(marker: CartesianMarker, targets: List) { + targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + } - override fun onUpdated(marker: CartesianMarker, targets: List) { - targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + override fun onUpdated(marker: CartesianMarker, targets: List) { + targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + } } } - } - CartesianChartHost( - chart = - @Suppress("SpreadOperator") - rememberCartesianChart( - *layers.toTypedArray(), - startAxis = startAxis, - endAxis = endAxis, - bottomAxis = bottomAxis, - marker = marker, - markerVisibilityListener = markerVisibilityListener, - persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null }, - fadingEdges = rememberFadingEdges(), - decorations = decorations, - ), - modelProducer = modelProducer, - modifier = modifier, - scrollState = vicoScrollState, - zoomState = zoomState, - ) + CartesianChartHost( + chart = + @Suppress("SpreadOperator") + rememberCartesianChart( + *layers.toTypedArray(), + startAxis = startAxis, + endAxis = endAxis, + bottomAxis = bottomAxis, + marker = marker, + markerVisibilityListener = markerVisibilityListener, + persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null }, + fadingEdges = rememberFadingEdges(), + decorations = decorations, + // Telemetry timestamps arrive at irregular intervals. Without an explicit + // x-step, Vico computes the GCD of consecutive x-value differences which can + // be as small as 1 second, making the chart logically enormous. A 60-second + // floor keeps the internal slot count reasonable for any practical interval. + getXStep = { model -> maxOf(model.getXDeltaGcd(), MIN_X_STEP_SECONDS) }, + ), + modelProducer = modelProducer, + modifier = modifier, + scrollState = vicoScrollState, + zoomState = zoomState, + ) + } } /** @@ -148,26 +159,28 @@ fun GenericMetricChart( * * @param isEmpty Whether the chart data is empty — when true, nothing is rendered. * @param legendData Legend items shown below the chart. - * @param key Optional key for the [CartesianChartModelProducer] (e.g. a selected channel). Pass a different value to - * recreate the producer. * @param hiddenSet Indices of hidden legend items (toggleable legend). * @param onToggle Callback when a legend item is toggled; when null, a read-only legend is rendered. * @param content Builder lambda receiving the [CartesianChartModelProducer] and a standard `Modifier.weight(1f)` * suitable for the chart area. + * + * A single [CartesianChartModelProducer] is created per scaffold instance. Vico forbids swapping the producer attached + * to a live [CartesianChartHost] (it throws "A new `CartesianChartModelProducer` was provided…"), so callers must push + * new data through [CartesianChartModelProducer.runTransaction] instead of recreating the producer. Keying the scaffold + * on external state (e.g. a selected channel) caused exactly that crash, so the previous `key` parameter was removed. */ @Composable fun MetricChartScaffold( isEmpty: Boolean, legendData: List, modifier: Modifier = Modifier, - key: Any? = Unit, hiddenSet: Set = emptySet(), onToggle: ((Int) -> Unit)? = null, content: @Composable ColumnScope.(CartesianChartModelProducer, Modifier) -> Unit, ) { Column(modifier = modifier) { if (isEmpty) return@Column - val modelProducer = remember(key) { CartesianChartModelProducer() } + val modelProducer = remember { CartesianChartModelProducer() } val chartModifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp) content(modelProducer, chartModifier) Legend( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt index c1cf0e04e..da8b16e47 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt @@ -57,7 +57,7 @@ import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent * **Design principles** (per [design#53](https://github.com/meshtastic/design/issues/53)): * - Default to thin lines **without** point markers to avoid clutter on dense timeseries. * - Show a single dot only at the marker/cursor position (handled by [rememberMarker]). - * - Use `Interpolator.catmullRom()` for smooth curves that pass through every data point. + * - Use `Interpolator.cubic()` for smooth monotone curves that won't overshoot between sparse points. * - Reserve bold lines for the single most-important series; use subtle/gradient fills for secondary data. */ @Suppress("TooManyFunctions") @@ -73,15 +73,21 @@ object ChartStyling { * * @param lineColor The color of the line * @param lineWidth Width of the line in dp + * @param interpolator The line interpolation strategy. Defaults to monotone + * [cubic][LineCartesianLayer.Interpolator.cubic] which won't overshoot between sparse data points (unlike + * catmull-rom). Use [Sharp][LineCartesianLayer.Interpolator.Sharp] for discrete/integer metrics like hop counts. * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createStyledLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line = - LineCartesianLayer.rememberLine( - fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), - stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), - interpolator = LineCartesianLayer.Interpolator.catmullRom(), - ) + fun createStyledLine( + lineColor: Color, + lineWidth: Float = MEDIUM_LINE_WIDTH_DP, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( + fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), + stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), + interpolator = interpolator, + ) /** * Creates a line with a gradient area fill effect. Ideal for emphasising a single series or showing magnitude. The @@ -92,14 +98,18 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createGradientLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line { + fun createGradientLine( + lineColor: Color, + lineWidth: Float = MEDIUM_LINE_WIDTH_DP, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line { val gradientBrush = Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.05f))) return LineCartesianLayer.rememberLine( fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), areaFill = LineCartesianLayer.AreaFill.single(Fill(gradientBrush)), stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), - interpolator = LineCartesianLayer.Interpolator.catmullRom(), + interpolator = interpolator, ) } @@ -110,8 +120,11 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createBoldLine(lineColor: Color): LineCartesianLayer.Line = - createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP) + fun createBoldLine( + lineColor: Color, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP, interpolator = interpolator) /** * Creates a subtle line suitable for secondary metrics that should not dominate the chart. @@ -131,7 +144,10 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createDashedLine(lineColor: Color): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( + fun createDashedLine( + lineColor: Color, + interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), + ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), stroke = LineCartesianLayer.LineStroke.Dashed( @@ -139,7 +155,7 @@ object ChartStyling { dashLength = 6.dp, gapLength = 3.dp, ), - interpolator = LineCartesianLayer.Interpolator.catmullRom(), + interpolator = interpolator, ) /** diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt index bb6efdff6..f8d48dd59 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -127,6 +127,8 @@ data class LegendData( val color: Color, val isLine: Boolean = false, val metricKey: Any? = null, + /** When non-null, overrides the resolved [nameRes] string in the legend label. */ + val labelOverride: String? = null, ) data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color) @@ -153,11 +155,12 @@ fun Legend( ) { legendData.forEachIndexed { index, data -> val isVisible = index !in hiddenSet + val label = data.labelOverride ?: stringResource(data.nameRes) if (onToggle != null) { FilterChip( selected = isVisible, onClick = { onToggle(index) }, - label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) }, + label = { Text(text = label, style = MaterialTheme.typography.labelSmall) }, leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) }, modifier = Modifier.padding(horizontal = 2.dp), ) @@ -166,7 +169,7 @@ fun Legend( LegendIndicator(color = data.color, isLine = data.isLine) Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(data.nameRes), + text = label, color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelSmall.fontSize, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 5725da604..609048a92 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -55,6 +55,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType @@ -230,12 +232,13 @@ private fun DeviceMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> + val formatted = NumberFormatter.format(value, 1) when (color) { - batteryColor -> formatString(percentValueTemplate, batteryLabel, value) - voltageColor -> formatString(voltageValueTemplate, voltageLabel, value) - chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value) - airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value) - else -> formatString(numericValueTemplate, value) + batteryColor -> formatString(percentValueTemplate, batteryLabel, formatted) + voltageColor -> formatString(voltageValueTemplate, voltageLabel, formatted) + chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, formatted) + airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, formatted) + else -> formatString(numericValueTemplate, formatted) } }, ) @@ -304,12 +307,13 @@ private fun DeviceMetricsChart( } } + val percentRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0) } val leftLayer = rememberConditionalLayer( hasData = leftLayerSeriesStyles.isNotEmpty(), lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles), verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0), + rangeProvider = percentRangeProvider, ) val rightLayer = @@ -337,7 +341,7 @@ private fun DeviceMetricsChart( if (leftLayer != null) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = batteryColor), - valueFormatter = { _, value, _ -> formatString("%.0f%%", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.percent(value.toFloat(), 0) }, ) } else { null @@ -346,7 +350,7 @@ private fun DeviceMetricsChart( if (rightLayer != null) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, + valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" }, ) } else { null @@ -441,7 +445,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick formatString( percentValueTemplate, channelUtilizationLabel, - deviceMetrics.channel_utilization ?: 0f, + NumberFormatter.format(deviceMetrics.channel_utilization ?: 0f, 1), ), ) Spacer(Modifier.width(12.dp)) @@ -453,7 +457,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick formatString( percentValueTemplate, airUtilizationLabel, - deviceMetrics.air_util_tx ?: 0f, + NumberFormatter.format(deviceMetrics.air_util_tx ?: 0f, 1), ), ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index c0164dd80..5029729ca 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -42,6 +42,7 @@ import org.meshtastic.core.resources.baro_pressure import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.lux +import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.soil_moisture import org.meshtastic.core.resources.soil_temperature @@ -112,6 +113,27 @@ private val LEGEND_DATA_3 = ), ) +private val LEGEND_DATA_4 = + listOf( + Environment.ONE_WIRE_TEMP_1, + Environment.ONE_WIRE_TEMP_2, + Environment.ONE_WIRE_TEMP_3, + Environment.ONE_WIRE_TEMP_4, + Environment.ONE_WIRE_TEMP_5, + Environment.ONE_WIRE_TEMP_6, + Environment.ONE_WIRE_TEMP_7, + Environment.ONE_WIRE_TEMP_8, + ) + .mapIndexed { index, entry -> + LegendData( + nameRes = Res.string.one_wire_temperature, + labelOverride = "1-Wire Temp ${index + 1}", + color = entry.color, + isLine = true, + metricKey = entry, + ) + } + @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun EnvironmentMetricsChart( @@ -132,18 +154,18 @@ fun EnvironmentMetricsChart( val onSurfaceColor = MaterialTheme.colorScheme.onSurface val allLegendData = - (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter { + (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4).filter { graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0] } - // Legend toggle state: tracks indices into allLegendData that are hidden - var hiddenIndices by remember { mutableStateOf(emptySet()) } - val hiddenMetrics = - remember(hiddenIndices, allLegendData) { - hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet() + // Track hidden metrics by key (not index) so toggling survives changes in allLegendData ordering. + var hiddenMetrics by remember { mutableStateOf(emptySet()) } + val hiddenIndices = + remember(hiddenMetrics, allLegendData) { + allLegendData.indices.filter { (allLegendData[it].metricKey as? Environment) in hiddenMetrics }.toSet() } - val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) } + val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) } val showPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && Environment.BAROMETRIC_PRESSURE !in hiddenMetrics @@ -211,6 +233,7 @@ fun EnvironmentMetricsChart( }, ) + val pressureRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0) } val layers = mutableListOf() if (showPressure && pressureData.isNotEmpty()) { layers.add( @@ -222,7 +245,7 @@ fun EnvironmentMetricsChart( verticalAxisPosition = Axis.Position.Vertical.Start, // Fixed range per Oscar's UX guidance: barometric pressure should NOT autoscale, // otherwise trends (storms) are invisible. 700-1200 hPa covers sea-level to altitude. - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0), + rangeProvider = pressureRangeProvider, ), ) } @@ -232,7 +255,7 @@ fun EnvironmentMetricsChart( when (metric) { Environment.RADIATION, Environment.WIND_SPEED, - -> CartesianLayerRangeProvider.fixed(minY = 0.0) + -> CartesianLayerRangeProvider.auto() else -> null } val lineStyle = @@ -288,7 +311,8 @@ fun EnvironmentMetricsChart( modifier = Modifier.padding(top = 0.dp), hiddenSet = hiddenIndices, onToggle = { index -> - hiddenIndices = if (index in hiddenIndices) hiddenIndices - index else hiddenIndices + index + val metric = allLegendData.getOrNull(index)?.metricKey as? Environment ?: return@Legend + hiddenMetrics = if (metric in hiddenMetrics) hiddenMetrics - metric else hiddenMetrics + metric }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 4f9e88d47..d09bdc8d1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType @@ -54,6 +55,7 @@ import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.iaq_definition import org.meshtastic.core.resources.lux +import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.rainfall_1h import org.meshtastic.core.resources.rainfall_24h @@ -164,7 +166,10 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.HUMIDITY.color) Spacer(Modifier.width(4.dp)) Text( - text = formatString("%s %.2f%%", stringResource(Res.string.humidity), humidity), + text = + "${stringResource( + Res.string.humidity, + )} ${MetricFormatter.percent(humidity, decimalPlaces = 2)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -177,7 +182,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.BAROMETRIC_PRESSURE.color) Spacer(Modifier.width(4.dp)) Text( - text = formatString("%.2f hPa", pressure), + text = MetricFormatter.pressure(pressure, decimalPlaces = 2), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -285,7 +290,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasVoltage) { val voltage = envMetrics.voltage!! Text( - text = formatString("%s %.2f V", stringResource(Res.string.voltage), voltage), + text = "${stringResource(Res.string.voltage)} ${MetricFormatter.voltage(voltage)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -293,7 +298,10 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasCurrent) { val currentValue = envMetrics.current!! Text( - text = formatString("%s %.2f mA", stringResource(Res.string.current), currentValue), + text = + "${stringResource( + Res.string.current, + )} ${MetricFormatter.current(currentValue, decimalPlaces = 2)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -386,7 +394,11 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { envMetrics.wind_direction!!, ) } else { - formatString("%s %.1f m/s", stringResource(Res.string.wind_speed), envMetrics.wind_speed!!) + formatString( + "%s %s", + stringResource(Res.string.wind_speed), + MetricFormatter.windSpeed(envMetrics.wind_speed!!), + ) } Text( text = dirText, @@ -402,14 +414,14 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (hasGust) { Text( - text = formatString("%s %.1f m/s", stringResource(Res.string.wind_gust), envMetrics.wind_gust!!), + text = "${stringResource(Res.string.wind_gust)} ${MetricFormatter.windSpeed(envMetrics.wind_gust!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) } if (hasLull) { Text( - text = formatString("%s %.1f m/s", stringResource(Res.string.wind_lull), envMetrics.wind_lull!!), + text = "${stringResource(Res.string.wind_lull)} ${MetricFormatter.windSpeed(envMetrics.wind_lull!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -426,7 +438,10 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (has1h) { Text( - text = formatString("%s %.1f mm", stringResource(Res.string.rainfall_1h), envMetrics.rainfall_1h!!), + text = + "${stringResource( + Res.string.rainfall_1h, + )} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -434,7 +449,9 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) if (has24h) { Text( text = - formatString("%s %.1f mm", stringResource(Res.string.rainfall_24h), envMetrics.rainfall_24h!!), + "${stringResource( + Res.string.rainfall_24h, + )} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -443,6 +460,39 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) } } +@Composable +private fun OneWireTemperatureDisplay( + envMetrics: org.meshtastic.proto.EnvironmentMetrics, + environmentDisplayFahrenheit: Boolean, +) { + val sensors = envMetrics.one_wire_temperature.filterNot { it.isNaN() } + if (sensors.isEmpty()) return + val oneWireEntries = + listOf( + Environment.ONE_WIRE_TEMP_1, + Environment.ONE_WIRE_TEMP_2, + Environment.ONE_WIRE_TEMP_3, + Environment.ONE_WIRE_TEMP_4, + Environment.ONE_WIRE_TEMP_5, + Environment.ONE_WIRE_TEMP_6, + Environment.ONE_WIRE_TEMP_7, + Environment.ONE_WIRE_TEMP_8, + ) + val textFormat = if (environmentDisplayFahrenheit) "%s %d: %.1f°F" else "%s %d: %.1f°C" + sensors.forEachIndexed { idx, temp -> + val color = oneWireEntries.getOrNull(idx)?.color ?: Environment.ONE_WIRE_TEMP_1.color + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(color) + Spacer(Modifier.width(4.dp)) + Text( + text = formatString(textFormat, stringResource(Res.string.one_wire_temperature), idx + 1, temp), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } + } +} + @Composable private fun EnvironmentMetricsCard( telemetry: Telemetry, @@ -484,6 +534,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa RadiationDisplay(envMetrics) WindDisplay(envMetrics) RainfallDisplay(envMetrics) + OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt index dda094e21..686a228b2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt @@ -18,16 +18,24 @@ package org.meshtastic.feature.node.metrics import androidx.compose.ui.graphics.Color import org.meshtastic.core.model.util.UnitConversions +import org.meshtastic.core.ui.theme.GraphColors.Amber import org.meshtastic.core.ui.theme.GraphColors.Blue +import org.meshtastic.core.ui.theme.GraphColors.Chartreuse +import org.meshtastic.core.ui.theme.GraphColors.Coral import org.meshtastic.core.ui.theme.GraphColors.Cyan +import org.meshtastic.core.ui.theme.GraphColors.DeepOrange import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.Green +import org.meshtastic.core.ui.theme.GraphColors.Indigo import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue +import org.meshtastic.core.ui.theme.GraphColors.LightGreen import org.meshtastic.core.ui.theme.GraphColors.Lime +import org.meshtastic.core.ui.theme.GraphColors.Magenta import org.meshtastic.core.ui.theme.GraphColors.Orange import org.meshtastic.core.ui.theme.GraphColors.Pink import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.core.ui.theme.GraphColors.Red +import org.meshtastic.core.ui.theme.GraphColors.SkyBlue import org.meshtastic.core.ui.theme.GraphColors.Teal import org.meshtastic.proto.Telemetry @@ -66,7 +74,39 @@ enum class Environment(val color: Color) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.wind_speed }, RADIATION(Lime) { - override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.radiation + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.radiation + }, + ONE_WIRE_TEMP_1(Amber) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(0) + }, + ONE_WIRE_TEMP_2(DeepOrange) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(1) + }, + ONE_WIRE_TEMP_3(Indigo) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(2) + }, + ONE_WIRE_TEMP_4(LightGreen) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(3) + }, + ONE_WIRE_TEMP_5(Magenta) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(4) + }, + ONE_WIRE_TEMP_6(SkyBlue) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(5) + }, + ONE_WIRE_TEMP_7(Chartreuse) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(6) + }, + ONE_WIRE_TEMP_8(Coral) { + override fun getValue(telemetry: Telemetry): Float? = + telemetry.environment_metrics?.one_wire_temperature?.getOrNull(7) }, ; abstract fun getValue(telemetry: Telemetry): Float? @@ -205,6 +245,33 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp shouldPlot[Environment.RADIATION.ordinal] = true } + // 1-Wire temperature sensors (up to 8 channels, Fahrenheit-aware) + val oneWireEntries = + listOf( + Environment.ONE_WIRE_TEMP_1, + Environment.ONE_WIRE_TEMP_2, + Environment.ONE_WIRE_TEMP_3, + Environment.ONE_WIRE_TEMP_4, + Environment.ONE_WIRE_TEMP_5, + Environment.ONE_WIRE_TEMP_6, + Environment.ONE_WIRE_TEMP_7, + Environment.ONE_WIRE_TEMP_8, + ) + oneWireEntries.forEach { entry -> + val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } } + if (values.isNotEmpty()) { + var minVal = values.minOf { it } + var maxVal = values.maxOf { it } + if (useFahrenheit) { + minVal = UnitConversions.celsiusToFahrenheit(minVal) + maxVal = UnitConversions.celsiusToFahrenheit(maxVal) + } + minValues.add(minVal) + maxValues.add(maxVal) + shouldPlot[entry.ordinal] = true + } + } + val min = if (minValues.isEmpty()) 0f else minValues.minOf { it } val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 8a051aaf2..10a3fe427 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -23,7 +23,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull @@ -31,16 +30,14 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import okio.ByteString.Companion.decodeBase64 import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers @@ -62,6 +59,7 @@ import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.view_on_map import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.toMessageRes +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase @@ -106,7 +104,7 @@ open class MetricsViewModel( if (nodeId == null) return@flatMapLatest flowOf(MetricsState.Empty) getNodeDetailsUseCase(nodeId).map { it.metricsState } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MetricsState.Empty) + .stateInWhileSubscribed(initialValue = MetricsState.Empty) private val environmentState: StateFlow = activeNodeId @@ -114,7 +112,7 @@ open class MetricsViewModel( if (nodeId == null) return@flatMapLatest flowOf(EnvironmentMetricsState()) getNodeDetailsUseCase(nodeId).map { it.environmentState } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EnvironmentMetricsState()) + .stateInWhileSubscribed(initialValue = EnvironmentMetricsState()) private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) @@ -150,6 +148,8 @@ open class MetricsViewModel( temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, soil_temperature = em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, + one_wire_temperature = + em.one_wire_temperature.map { UnitConversions.celsiusToFahrenheit(it) }, ), ) } @@ -183,7 +183,8 @@ open class MetricsViewModel( fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum) - fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } + fun deleteLog(uuid: String) = + safeLaunch(context = dispatchers.io, tag = "deleteLog") { meshLogRepository.deleteLog(uuid) } fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { val cached = tracerouteOverlayCache.value[requestId] @@ -218,7 +219,7 @@ open class MetricsViewModel( private fun List.numSet(): Set = map { it.num }.toSet() init { - viewModelScope.launch { + safeLaunch(tag = "tracerouteCollector") { serviceRepository.tracerouteResponse.filterNotNull().collect { response -> val overlay = TracerouteOverlay( @@ -234,7 +235,7 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel created" } } - fun clearPosition() = viewModelScope.launch(dispatchers.io) { + fun clearPosition() = safeLaunch(context = dispatchers.io, tag = "clearPosition") { (manualNodeId.value ?: nodeIdFromRoute)?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) } @@ -278,7 +279,7 @@ open class MetricsViewModel( overlay: TracerouteOverlay?, onViewOnMap: (Int, String) -> Unit, ) { - viewModelScope.launch { + safeLaunch(tag = "showTracerouteDetail") { val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first() alertManager.showAlert( titleRes = Res.string.traceroute, @@ -301,7 +302,7 @@ open class MetricsViewModel( if (errorRes != null) { // Post the error alert after the current alert is dismissed to avoid // the wrapping dismissAlert() in AlertManager immediately clearing it. - viewModelScope.launch { + safeLaunch(tag = "tracerouteError") { alertManager.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) } } else { @@ -332,13 +333,13 @@ open class MetricsViewModel( * epoch-seconds timestamp extracted by [epochSeconds]. */ private fun exportCsv( - uri: MeshtasticUri, + uri: CommonUri, header: String, rows: List, epochSeconds: (T) -> Long, rowMapper: (T) -> String, ) { - viewModelScope.launch(dispatchers.io) { + safeLaunch(context = dispatchers.io, tag = "exportCsv") { fileService.write(uri) { sink -> sink.writeUtf8(header) rows.forEach { item -> @@ -350,11 +351,10 @@ open class MetricsViewModel( } } - fun savePositionCSV(uri: MeshtasticUri, data: List) { + fun savePositionCSV(uri: CommonUri, data: List) { exportCsv( uri = uri, - header = - "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\"," + "\"satsInView\",\"speed\",\"heading\"\n", + header = "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n", rows = data, epochSeconds = { it.time.toLong() }, ) { pos -> @@ -365,7 +365,7 @@ open class MetricsViewModel( } } - fun saveDeviceMetricsCSV(uri: MeshtasticUri, data: List) { + fun saveDeviceMetricsCSV(uri: CommonUri, data: List) { exportCsv( uri = uri, header = @@ -381,26 +381,30 @@ open class MetricsViewModel( } } - fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List) { + fun saveEnvironmentMetricsCSV(uri: CommonUri, data: List) { + val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" } exportCsv( uri = uri, header = "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," + "\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," + - "\"soilMoisture\"\n", + "\"soilMoisture\",$oneWireHeaders\n", rows = data, epochSeconds = { it.time.toLong() }, ) { t -> val em = t.environment_metrics + val owt = em?.one_wire_temperature ?: emptyList() + val oneWireValues = + (0 until ONE_WIRE_SENSOR_COUNT).joinToString(",") { i -> "\"${owt.getOrNull(i) ?: ""}\"" } "\"${em?.temperature ?: ""}\",\"${em?.relative_humidity ?: ""}\"," + "\"${em?.barometric_pressure ?: ""}\",\"${em?.gas_resistance ?: ""}\"," + "\"${em?.iaq ?: ""}\",\"${em?.wind_speed ?: ""}\"," + "\"${em?.wind_direction ?: ""}\",\"${em?.soil_temperature ?: ""}\"," + - "\"${em?.soil_moisture ?: ""}\"" + "\"${em?.soil_moisture ?: ""}\",$oneWireValues" } } - fun saveSignalMetricsCSV(uri: MeshtasticUri, data: List) { + fun saveSignalMetricsCSV(uri: CommonUri, data: List) { exportCsv( uri = uri, header = "\"date\",\"time\",\"rssi\",\"snr\"\n", @@ -411,7 +415,7 @@ open class MetricsViewModel( } } - fun savePowerMetricsCSV(uri: MeshtasticUri, data: List) { + fun savePowerMetricsCSV(uri: CommonUri, data: List) { exportCsv( uri = uri, header = @@ -458,4 +462,8 @@ open class MetricsViewModel( } protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0) + + companion object { + private const val ONE_WIRE_SENSOR_COUNT = 8 + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index 598cd5ca9..b3b0b36e0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -141,11 +141,20 @@ private fun PaxMetricsChart( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine(lineColor = bleColor), - ChartStyling.createGradientLine(lineColor = wifiColor), - ChartStyling.createBoldLine(lineColor = paxColor), + ChartStyling.createGradientLine( + lineColor = bleColor, + interpolator = LineCartesianLayer.Interpolator.Sharp, + ), + ChartStyling.createGradientLine( + lineColor = wifiColor, + interpolator = LineCartesianLayer.Interpolator.Sharp, + ), + ChartStyling.createBoldLine( + lineColor = paxColor, + interpolator = LineCartesianLayer.Interpolator.Sharp, + ), ), - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + rangeProvider = CartesianLayerRangeProvider.auto(), ), ), startAxis = VerticalAxis.rememberStart(label = axisLabel), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index c815f6622..5a71659f8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -54,7 +54,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res @@ -181,12 +182,10 @@ private fun PowerMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - MetricChartScaffold( - isEmpty = telemetries.isEmpty(), - legendData = LEGEND_DATA, - modifier = modifier, - key = selectedChannel, - ) { modelProducer, chartModifier -> + MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> val currentColor = PowerMetric.CURRENT.color val voltageColor = PowerMetric.VOLTAGE.color val marker = @@ -194,9 +193,9 @@ private fun PowerMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> when (color) { - currentColor -> formatString("Current: %.0f mA", value) - voltageColor -> formatString("Voltage: %.1f V", value) - else -> formatString("%.1f", value) + currentColor -> "Current: ${MetricFormatter.current(value.toFloat(), 0)}" + voltageColor -> "Voltage: ${NumberFormatter.format(value.toFloat(), 1)} V" + else -> NumberFormatter.format(value.toFloat(), 1) } }, ) @@ -256,7 +255,7 @@ private fun PowerMetricsChart( if (currentData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = currentColor), - valueFormatter = { _, value, _ -> formatString("%.0f mA", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.current(value.toFloat(), 0) }, ) } else { null @@ -265,7 +264,7 @@ private fun PowerMetricsChart( if (voltageData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, + valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" }, ) } else { null @@ -369,8 +368,8 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current style = TextStyle(fontWeight = FontWeight.Bold), fontSize = MaterialTheme.typography.labelLarge.fontSize, ) - MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage)) - MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current)) + MetricValueRow(color = PowerMetric.VOLTAGE.color, text = MetricFormatter.voltage(voltage)) + MetricValueRow(color = PowerMetric.CURRENT.color, text = MetricFormatter.current(current)) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index e8b184427..4931d8c59 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -47,7 +47,7 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res @@ -157,9 +157,9 @@ private fun SignalMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> if (color == rssiColor) { - formatString("RSSI: %.0f dBm", value) + "RSSI: ${MetricFormatter.rssi(value.toInt())}" } else { - formatString("SNR: %.1f dB", value) + "SNR: ${MetricFormatter.snr(value.toFloat())}" } }, ) @@ -189,7 +189,7 @@ private fun SignalMetricsChart( if (rssiData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = rssiColor), - valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.rssi(value.toInt()) }, ) } else { null @@ -198,7 +198,7 @@ private fun SignalMetricsChart( if (snrData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = snrColor), - valueFormatter = { _, value, _ -> formatString("%.1f dB", value) }, + valueFormatter = { _, value, _ -> MetricFormatter.snr(value.toFloat()) }, ) } else { null @@ -234,15 +234,9 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli /* SNR and RSSI */ Row(verticalAlignment = Alignment.CenterVertically) { - MetricValueRow( - color = SignalMetric.RSSI.color, - text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()), - ) + MetricValueRow(color = SignalMetric.RSSI.color, text = MetricFormatter.rssi(meshPacket.rx_rssi)) Spacer(Modifier.width(12.dp)) - MetricValueRow( - color = SignalMetric.SNR.color, - text = formatString("%.1f dB", meshPacket.rx_snr), - ) + MetricValueRow(color = SignalMetric.SNR.color, text = MetricFormatter.snr(meshPacket.rx_snr)) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt index ce6300205..c27f111d1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt @@ -112,7 +112,7 @@ internal fun resolveTraceroutePoints(requests: List, results: List, scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val nodeListViewModel: NodeListViewModel = koinViewModel() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index facb5a9d7..233942f00 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -19,6 +19,7 @@ package org.meshtastic.feature.node.navigation import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -72,7 +73,7 @@ import kotlin.reflect.KClass fun EntryProviderScope.nodesGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( @@ -98,7 +99,7 @@ fun EntryProviderScope.nodesGraph( fun EntryProviderScope.nodeDetailGraph( backStack: NavBackStack, scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { entry(metadata = { ListDetailSceneStrategy.listPane() }) { args -> AdaptiveNodeListScreen( @@ -116,9 +117,9 @@ fun EntryProviderScope.nodeDetailGraph( nodeId = destNum, viewModel = nodeDetailViewModel, compassViewModel = compassViewModel, - navigateToMessages = { backStack.add(ContactsRoute.Messages(it)) }, - onNavigate = { backStack.add(it) }, - onNavigateUp = { backStack.removeLastOrNull() }, + navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) }, + onNavigate = { route -> backStack.add(route) }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -128,7 +129,7 @@ fun EntryProviderScope.nodeDetailGraph( TracerouteLogScreen( viewModel = metricsViewModel, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, onViewOnMap = { requestId, responseLogUuid -> backStack.add( NodeDetailRoute.TracerouteMap( @@ -182,7 +183,7 @@ private inline fun EntryProviderScope.addNodeDetailS val metricsViewModel = koinViewModel { parametersOf(destNum) } metricsViewModel.setNodeId(destNum) - routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() } + routeInfo.screenComposable(metricsViewModel, dropUnlessResumed { backStack.removeLastOrNull() }) } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt new file mode 100644 index 000000000..6bca8822b --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.detail + +import androidx.lifecycle.SavedStateHandle +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.feature.node.component.NodeMenuAction +import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase +import org.meshtastic.feature.node.model.NodeDetailAction +import org.meshtastic.proto.User +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse + +@OptIn(ExperimentalCoroutinesApi::class) +class HandleNodeActionTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val nodeManagementActions: NodeManagementActions = mock() + private val nodeRequestActions: NodeRequestActions = mock() + private val serviceRepository: ServiceRepository = mock() + private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { getNodeDetailsUseCase(any()) } returns emptyFlow() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `remove action delegates to viewModel and does not navigate up immediately`() = runTest(testDispatcher) { + val node = Node(num = 1234, user = User(id = "!1234")) + every { nodeManagementActions.requestRemoveNode(any(), any(), any()) } returns Unit + val viewModel = createViewModel() + var navigateUpCalled = false + + handleNodeAction( + action = NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node)), + uiState = NodeDetailUiState(), + navigateToMessages = {}, + onNavigateUp = { navigateUpCalled = true }, + onNavigate = {}, + viewModel = viewModel, + ) + + verify { nodeManagementActions.requestRemoveNode(any(), node, any()) } + assertFalse(navigateUpCalled) + } + + private fun createViewModel() = NodeDetailViewModel( + savedStateHandle = SavedStateHandle(mapOf("destNum" to 1234)), + nodeManagementActions = nodeManagementActions, + nodeRequestActions = nodeRequestActions, + serviceRepository = serviceRepository, + getNodeDetailsUseCase = getNodeDetailsUseCase, + ) +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 89015c807..3212a313e 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -30,6 +30,7 @@ import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.proto.User import kotlin.test.Test +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class NodeManagementActionsTest { @@ -69,4 +70,23 @@ class NodeManagementActionsTest { ) } } + + @Test + fun requestRemoveNode_invokes_onAfterRemove_when_user_confirms() { + val realAlertManager = AlertManager() + val actionsWithRealAlert = + NodeManagementActions( + nodeRepository = nodeRepository, + serviceRepository = serviceRepository, + radioController = radioController, + alertManager = realAlertManager, + ) + val node = Node(num = 123, user = User(long_name = "Test Node")) + var afterRemoveCalled = false + + actionsWithRealAlert.requestRemoveNode(testScope, node) { afterRemoveCalled = true } + realAlertManager.currentAlert.value?.onConfirm?.invoke() + + assertTrue(afterRemoveCalled) + } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt index 602134aa0..9511a2da1 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt @@ -32,6 +32,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.core.testing.FakeRadioInterfaceService import org.meshtastic.core.testing.TestDataFactory import org.meshtastic.feature.node.detail.NodeManagementActions import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase @@ -45,6 +46,7 @@ class NodeListViewModelTest { private lateinit var viewModel: NodeListViewModel private lateinit var nodeRepository: FakeNodeRepository private lateinit var radioController: FakeRadioController + private lateinit var radioInterfaceService: FakeRadioInterfaceService private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) private val nodeFilterPreferences: NodeFilterPreferences = mock(MockMode.autofill) @@ -55,6 +57,7 @@ class NodeListViewModelTest { fun setUp() { nodeRepository = FakeNodeRepository() radioController = FakeRadioController() + radioInterfaceService = FakeRadioInterfaceService() every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(org.meshtastic.proto.DeviceProfile()) @@ -79,6 +82,7 @@ class NodeListViewModelTest { radioConfigRepository = radioConfigRepository, serviceRepository = serviceRepository, radioController = radioController, + radioInterfaceService = radioInterfaceService, nodeManagementActions = nodeManagementActions, getFilteredNodesUseCase = getFilteredNodesUseCase, nodeFilterPreferences = nodeFilterPreferences, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index 961a34dd6..956c20175 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import okio.Buffer import okio.BufferedSink -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 org.meshtastic.core.repository.MeshLogRepository @@ -210,7 +210,7 @@ class MetricsViewModelTest { awaitItem() // Empty awaitItem() // with position - val uri = MeshtasticUri("content://test") + val uri = CommonUri.parse("content://test") vm.savePositionCSV(uri, listOf(testPosition)) runCurrent() diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt index 060925fb3..a80b2172e 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt @@ -241,6 +241,19 @@ class TracerouteChartTest { assertNull(point.returnHops) } + @Test + fun timeSeconds_truncatesSubSecondPrecision() { + // received_date with sub-second remainder (e.g. 1000 seconds + 456 ms) + val requestTime = 1000L * MS_PER_SEC + 456L + val requests = listOf(makeRequest(id = 1, receivedDateMillis = requestTime)) + val results = emptyList() + + val point = resolveTraceroutePoints(requests, results).first() + + // Must truncate to whole seconds to avoid Vico "x-values are too precise" crash + assertEquals(1000.0, point.timeSeconds) + } + @Test fun returnHops_computedWhenRouteBackAvailable() { val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 2793f3625..c33a6f353 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.meshtastic.kmp.feature) alias(libs.plugins.meshtastic.kotlinx.serialization) + id("meshtastic.kmp.jvm.android") } kotlin { diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index c33c3a293..82cd4b7be 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -30,16 +30,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.eygraber.uri.toKmpUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.toDate import org.meshtastic.core.common.util.toInstant -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.WifiProvisionRoute @@ -57,6 +57,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.settings.component.AppInfoSection import org.meshtastic.feature.settings.component.AppearanceSection +import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.PersistenceSection import org.meshtastic.feature.settings.component.PrivacySection @@ -72,7 +73,6 @@ import org.meshtastic.proto.DeviceProfile import java.text.SimpleDateFormat import java.util.Locale -@OptIn(ExperimentalPermissionsApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun SettingsScreen( @@ -90,14 +90,14 @@ fun SettingsScreen( val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var deviceProfile by remember { mutableStateOf(null) } - var showEditDeviceProfileDialog by remember { mutableStateOf(false) } + var showEditDeviceProfileDialog by rememberSaveable { mutableStateOf(false) } val importConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { showEditDeviceProfileDialog = true it.data?.data?.let { uri -> - viewModel.importProfile(uri.toMeshtasticUri()) { profile -> deviceProfile = profile } + viewModel.importProfile(uri.toKmpUri()) { profile -> deviceProfile = profile } } } } @@ -105,7 +105,7 @@ fun SettingsScreen( val exportConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportProfile(uri.toMeshtasticUri(), deviceProfile!!) } + it.data?.data?.let { uri -> viewModel.exportProfile(uri.toKmpUri(), deviceProfile!!) } } } @@ -144,12 +144,12 @@ fun SettingsScreen( ) } - var showLanguagePickerDialog by remember { mutableStateOf(false) } + var showLanguagePickerDialog by rememberSaveable { mutableStateOf(false) } if (showLanguagePickerDialog) { LanguagePickerDialog { showLanguagePickerDialog = false } } - var showThemePickerDialog by remember { mutableStateOf(false) } + var showThemePickerDialog by rememberSaveable { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -157,6 +157,14 @@ fun SettingsScreen( ) } + var showContrastPickerDialog by remember { mutableStateOf(false) } + if (showContrastPickerDialog) { + ContrastPickerDialog( + onClickContrast = { settingsViewModel.setContrastLevel(it) }, + onDismiss = { showContrastPickerDialog = false }, + ) + } + Scaffold( topBar = { MainAppBar( @@ -229,6 +237,7 @@ fun SettingsScreen( AppearanceSection( onShowLanguagePicker = { showLanguagePickerDialog = true }, onShowThemePicker = { showThemePickerDialog = true }, + onShowContrastPicker = { showContrastPickerDialog = true }, ) ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { @@ -241,7 +250,7 @@ fun SettingsScreen( cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value, onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) }, nodeShortName = ourNode?.user?.short_name ?: "", - onExportData = { settingsViewModel.saveDataCsv(it.toMeshtasticUri()) }, + onExportData = { settingsViewModel.saveDataCsv(it.toKmpUri()) }, ) AppInfoSection( diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt index f70cda978..cb61c8295 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt @@ -28,6 +28,7 @@ import androidx.core.net.toUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_settings +import org.meshtastic.core.resources.contrast import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.theme import org.meshtastic.core.ui.component.ListItem @@ -37,9 +38,13 @@ import org.meshtastic.core.ui.icon.Language import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme -/** Section for app appearance settings like language and theme. */ +/** Section for app appearance settings like language, theme, and contrast. */ @Composable -fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) { +fun AppearanceSection( + onShowLanguagePicker: () -> Unit, + onShowThemePicker: () -> Unit, + onShowContrastPicker: () -> Unit, +) { val context = LocalContext.current val settingsLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {} @@ -74,11 +79,19 @@ fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> ) { onShowThemePicker() } + + ListItem( + text = stringResource(Res.string.contrast), + leadingIcon = MeshtasticIcons.FormatPaint, + trailingIcon = null, + ) { + onShowContrastPicker() + } } } @Preview(showBackground = true) @Composable private fun AppearanceSectionPreview() { - AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) } + AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}, onShowContrastPicker = {}) } } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index c251b4d5e..315ad1da8 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_export_failed import org.meshtastic.core.resources.debug_export_success @@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { try { if (logs.isEmpty()) { withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt index fe5e381f6..063add0d1 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt @@ -30,17 +30,26 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.import_label import org.meshtastic.core.resources.play +import org.meshtastic.core.resources.ringtone_file_empty +import org.meshtastic.core.resources.ringtone_import_error +import org.meshtastic.core.resources.ringtone_imported import org.meshtastic.core.ui.icon.FolderOpen import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.PlayArrow import java.io.File private const val MAX_RINGTONE_SIZE = 230 +private const val IMPORT_ERROR_PLACEHOLDER = "@@ERROR@@" @Suppress("TooGenericExceptionCaught") @Composable actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) { val context = LocalContext.current + val importedText = stringResource(Res.string.ringtone_imported) + val emptyText = stringResource(Res.string.ringtone_file_empty) + // Pre-resolve the format pattern for use in the non-composable launcher callback. + // Using a sentinel placeholder that will be replaced at call-site. + val importErrorPrefix = stringResource(Res.string.ringtone_import_error, IMPORT_ERROR_PLACEHOLDER) val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> @@ -52,15 +61,16 @@ actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (Stri val read = reader.read(buffer) if (read > 0) { onRingtoneImported(String(buffer, 0, read)) - Toast.makeText(context, "Imported ringtone", Toast.LENGTH_SHORT).show() + Toast.makeText(context, importedText, Toast.LENGTH_SHORT).show() } else { - Toast.makeText(context, "File is empty", Toast.LENGTH_SHORT).show() + Toast.makeText(context, emptyText, Toast.LENGTH_SHORT).show() } } } } catch (e: Exception) { Logger.e(e) { "Error importing ringtone" } - Toast.makeText(context, "Error importing: ${e.message}", Toast.LENGTH_SHORT).show() + val errorMsg = importErrorPrefix.replace(IMPORT_ERROR_PLACEHOLDER, e.message ?: e.toString()) + Toast.makeText(context, errorMsg, Toast.LENGTH_SHORT).show() } } } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt index 96e6890b2..15cd0e11d 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt @@ -30,9 +30,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.eygraber.uri.toKmpUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.export_keys import org.meshtastic.core.resources.export_keys_confirmation @@ -54,7 +54,7 @@ actual fun ExportSecurityConfigButton( val exportConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), securityConfig) } + it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toKmpUri(), securityConfig) } } } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt index 9afde85e5..a28a57678 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt @@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher @Composable actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { @@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr return { fileName -> exportLauncher.launch(fileName) } } -private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { +private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { try { context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } Logger.i { "TAK data package exported successfully to $targetUri" } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt index 499874e26..723448897 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt @@ -23,7 +23,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -private const val SDK_INT_ANDROID_16 = 37 +private val SDK_INT_ANDROID_16 = Build.VERSION_CODES.BAKLAVA @OptIn(ExperimentalPermissionsApi::class) @Composable diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 27c57fafe..ddad8296e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -17,7 +17,6 @@ package org.meshtastic.feature.settings import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,16 +24,16 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import okio.BufferedSink import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase +import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase @@ -51,6 +50,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.LocalConfig @@ -66,6 +66,7 @@ class SettingsViewModel( private val meshLogPrefs: MeshLogPrefs, private val notificationPrefs: NotificationPrefs, private val setThemeUseCase: SetThemeUseCase, + private val setContrastLevelUseCase: SetContrastLevelUseCase, private val setLocaleUseCase: SetLocaleUseCase, private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, private val setProvideLocationUseCase: SetProvideLocationUseCase, @@ -146,12 +147,12 @@ class SettingsViewModel( val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow() fun setMeshLogRetentionDays(days: Int) { - viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) } + safeLaunch(tag = "setMeshLogRetentionDays") { setMeshLogSettingsUseCase.setRetentionDays(days) } _meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) } fun setMeshLogLoggingEnabled(enabled: Boolean) { - viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } + safeLaunch(tag = "setMeshLogLoggingEnabled") { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } _meshLogLoggingEnabled.value = enabled } @@ -163,6 +164,10 @@ class SettingsViewModel( setThemeUseCase(theme) } + fun setContrastLevel(level: Int) { + setContrastLevelUseCase(level) + } + /** Set the application locale. Empty string means system default. */ fun setLocale(languageTag: String) { setLocaleUseCase(languageTag) @@ -182,8 +187,10 @@ class SettingsViewModel( * @param uri The destination URI for the CSV file. * @param filterPortnum If provided, only packets with this port number will be exported. */ - fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) { - viewModelScope.launch { fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } } + fun saveDataCsv(uri: CommonUri, filterPortnum: Int? = null) { + safeLaunch(tag = "saveDataCsv") { + fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } + } } private suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index f479e3d26..c1d36e2ee 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -17,11 +17,9 @@ package org.meshtastic.feature.settings.channel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.RadioController @@ -30,6 +28,7 @@ import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -86,7 +85,7 @@ class ChannelViewModel( } /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -97,12 +96,12 @@ class ChannelViewModel( } fun setChannel(channel: Channel) { - viewModelScope.launch { radioController.setLocalChannel(channel) } + safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { - viewModelScope.launch { radioController.setLocalConfig(config) } + safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } } fun trackShare() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt new file mode 100644 index 000000000..c8adc418a --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt @@ -0,0 +1,58 @@ +/* + * 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 . + */ +@file:Suppress("MatchingDeclarationName") + +package org.meshtastic.feature.settings.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.choose_contrast +import org.meshtastic.core.resources.contrast_high +import org.meshtastic.core.resources.contrast_medium +import org.meshtastic.core.resources.contrast_standard +import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.theme.ContrastLevel + +/** Contrast level options matching [ContrastLevel] ordinal values. */ +enum class ContrastOption(val label: StringResource, val level: ContrastLevel) { + STANDARD(label = Res.string.contrast_standard, level = ContrastLevel.STANDARD), + MEDIUM(label = Res.string.contrast_medium, level = ContrastLevel.MEDIUM), + HIGH(label = Res.string.contrast_high, level = ContrastLevel.HIGH), +} + +/** Shared dialog for picking a contrast level. Used by both Android and Desktop settings screens. */ +@Composable +fun ContrastPickerDialog(onClickContrast: (Int) -> Unit, onDismiss: () -> Unit) { + MeshtasticDialog( + title = stringResource(Res.string.choose_contrast), + onDismiss = onDismiss, + text = { + Column { + ContrastOption.entries.forEach { option -> + ListItem(text = stringResource(option.label), trailingIcon = null) { + onClickContrast(option.level.value) + onDismiss() + } + } + } + }, + ) +} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt similarity index 64% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt index d7910f2ea..3930580d1 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt @@ -16,15 +16,9 @@ */ package org.meshtastic.feature.settings.component -import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.analytics_okay import org.meshtastic.core.resources.app_settings @@ -34,11 +28,12 @@ import org.meshtastic.core.ui.component.SwitchListItem import org.meshtastic.core.ui.icon.BugReport import org.meshtastic.core.ui.icon.LocationOn import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.core.ui.util.showToast +import org.meshtastic.core.ui.util.isGpsDisabled +import org.meshtastic.core.ui.util.isLocationPermissionGranted +import org.meshtastic.core.ui.util.rememberRequestLocationPermission +import org.meshtastic.core.ui.util.rememberShowToastResource /** Section managing privacy settings like analytics and location sharing. */ -@OptIn(ExperimentalPermissionsApi::class) @Composable fun PrivacySection( analyticsAvailable: Boolean, @@ -51,21 +46,22 @@ fun PrivacySection( startProvideLocation: () -> Unit, stopProvideLocation: () -> Unit, ) { - val context = LocalContext.current - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - val isGpsDisabled = context.gpsDisabled() + val showToast = rememberShowToastResource() + val isLocationGranted = isLocationPermissionGranted() + val isGpsOff = isGpsDisabled() + val requestLocationPermission = + rememberRequestLocationPermission(onGranted = { startProvideLocation() }, onDenied = {}) - LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) { + LaunchedEffect(provideLocation, isLocationGranted, isGpsOff) { if (provideLocation) { - if (locationPermissionsState.allPermissionsGranted) { - if (!isGpsDisabled) { + if (isLocationGranted) { + if (!isGpsOff) { startProvideLocation() } else { - context.showToast(Res.string.location_disabled) + showToast(Res.string.location_disabled) } } else { - locationPermissionsState.launchMultiplePermissionRequest() + requestLocationPermission() } } else { stopProvideLocation() @@ -85,7 +81,7 @@ fun PrivacySection( SwitchListItem( text = stringResource(Res.string.provide_location_to_mesh), leadingIcon = MeshtasticIcons.LocationOn, - enabled = !isGpsDisabled, + enabled = !isGpsOff, checked = provideLocation, onClick = { onToggleLocation(!provideLocation) }, ) @@ -93,21 +89,3 @@ fun PrivacySection( HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph) } } - -@Preview(showBackground = true) -@Composable -private fun PrivacySectionPreview() { - AppTheme { - PrivacySection( - analyticsAvailable = true, - analyticsEnabled = true, - onToggleAnalytics = {}, - provideLocation = true, - onToggleLocation = {}, - homoglyphEnabled = false, - onToggleHomoglyph = {}, - startProvideLocation = {}, - stopProvideLocation = {}, - ) - } -} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt index 37cdeab71..df4a0965f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt @@ -57,8 +57,10 @@ import org.meshtastic.core.resources.debug_filter_clear import org.meshtastic.core.resources.debug_filter_included import org.meshtastic.core.resources.debug_filter_preset_title import org.meshtastic.core.resources.debug_filters +import org.meshtastic.core.resources.filter_icon import org.meshtastic.core.resources.match_all import org.meshtastic.core.resources.match_any +import org.meshtastic.core.resources.remove_filter import org.meshtastic.core.ui.icon.Add import org.meshtastic.core.ui.icon.Check import org.meshtastic.core.ui.icon.Close @@ -281,8 +283,18 @@ fun DebugActiveFilters( selected = true, onClick = { onFilterTextsChange(filterTexts - filter) }, label = { Text(filter) }, - leadingIcon = { Icon(imageVector = MeshtasticIcons.FilterAlt, contentDescription = null) }, - trailingIcon = { Icon(imageVector = MeshtasticIcons.Close, contentDescription = null) }, + leadingIcon = { + Icon( + imageVector = MeshtasticIcons.FilterAlt, + contentDescription = stringResource(Res.string.filter_icon), + ) + }, + trailingIcon = { + Icon( + imageVector = MeshtasticIcons.Close, + contentDescription = stringResource(Res.string.remove_filter), + ) + }, ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt index 6ed8cb427..1600ce947 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt @@ -35,7 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -158,7 +158,7 @@ fun DebugSearchState( onExportLogs: (() -> Unit)? = null, ) { val colorScheme = MaterialTheme.colorScheme - var customFilterText by remember { mutableStateOf("") } + var customFilterText by rememberSaveable { mutableStateOf("") } Column(modifier = modifier.background(color = colorScheme.background.copy(alpha = 1.0f)).padding(8.dp)) { Row( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 8ed442ccd..f04ade2e8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.DateFormatter @@ -47,6 +45,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_clear import org.meshtastic.core.resources.debug_clear_logs_confirm import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket @@ -62,15 +61,6 @@ import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint -data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String) - -data class SearchState( - val searchText: String = "", - val currentMatchIndex: Int = -1, - val allMatches: List = emptyList(), - val hasMatches: Boolean = false, -) - enum class FilterMode { AND, OR, @@ -265,16 +255,18 @@ class DebugViewModel( val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) meshLogPrefs.setRetentionDays(clamped) _retentionDays.value = clamped - viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) } + safeLaunch(tag = "setRetentionDays") { meshLogRepository.deleteLogsOlderThan(clamped) } } fun setLoggingEnabled(enabled: Boolean) { meshLogPrefs.setLoggingEnabled(enabled) _loggingEnabled.value = enabled if (!enabled) { - viewModelScope.launch { meshLogRepository.deleteAll() } + safeLaunch(tag = "disableLogging") { meshLogRepository.deleteAll() } } else { - viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) } + safeLaunch(tag = "enableLogging") { + meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) + } } } @@ -286,7 +278,7 @@ class DebugViewModel( init { Logger.d { "DebugViewModel created" } - viewModelScope.launch { + safeLaunch(tag = "searchMatchUpdater") { combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs -> searchManager.findSearchMatches(searchText, logs) } @@ -386,17 +378,15 @@ class DebugViewModel( val nodeIdStr = nodeId.toUInt().toString() // Only match if whitespace before and after val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""") - regex.find(this)?.let { _ -> - regex.findAll(this).toList().asReversed().forEach { - val idx = it.range.last + 1 - insert(idx, " (${nodeId.toHex(8)})") - } - return true + if (!regex.containsMatchIn(this)) return false + regex.findAll(this).toList().asReversed().forEach { + val idx = it.range.last + 1 + insert(idx, " (${nodeId.toHex(8)})") } - return false + return true } - private fun Int.toHex(length: Int): String = "!" + this.toUInt().toString(16).padStart(length, '0') + private fun Int.toHex(length: Int): String = "!${this.toUInt().toString(16).padStart(length, '0')}" fun requestDeleteAllLogs() { alertManager.showAlert( @@ -406,7 +396,7 @@ class DebugViewModel( ) } - fun deleteAllLogs() = viewModelScope.launch(ioDispatcher) { meshLogRepository.deleteAll() } + fun deleteAllLogs() = safeLaunch(context = ioDispatcher, tag = "deleteAllLogs") { meshLogRepository.deleteAll() } @Immutable data class UiMeshLog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 1409f6bdf..1ee791620 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -18,10 +18,10 @@ package org.meshtastic.feature.settings.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -79,7 +79,7 @@ fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewMod .lastOrNull { it is SettingsRoute.SettingsGraph } ?.let { (it as SettingsRoute.SettingsGraph).destNum } } - SideEffect { viewModel.initDestNum(destNum) } + LaunchedEffect(destNum) { viewModel.initDestNum(destNum) } return viewModel } @@ -106,7 +106,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { entry { DeviceConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) } @@ -117,13 +117,16 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { ModuleConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), excludedModulesUnlocked = excludedModulesUnlocked, - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) } entry { - AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() }) + AdministrationScreen( + viewModel = getRadioConfigViewModel(backStack), + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + ) } entry { @@ -135,16 +138,26 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { configComposable(routeInfo.route::class, backStack) { viewModel -> LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } when (routeInfo) { - ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.DEVICE -> DeviceConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POSITION -> PositionConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ConfigRoute.SECURITY -> SecurityConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.USER -> + UserConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.CHANNELS -> + ChannelConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.DEVICE -> + DeviceConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.POSITION -> + PositionConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.POWER -> + PowerConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.NETWORK -> + NetworkConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.DISPLAY -> + DisplayConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.LORA -> + LoRaConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.BLUETOOTH -> + BluetoothConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.SECURITY -> + SecurityConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } } @@ -153,50 +166,63 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { configComposable(routeInfo.route::class, backStack) { viewModel -> LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } when (routeInfo) { - ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.MQTT -> + MQTTConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.SERIAL -> + SerialConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreenCommon( viewModel = viewModel, - onBack = { backStack.removeLastOrNull() }, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, ) ModuleRoute.STORE_FORWARD -> - StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + StoreForwardConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.RANGE_TEST -> + RangeTestConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.TELEMETRY -> + TelemetryConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.CANNED_MESSAGE -> - CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + CannedMessageConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.AUDIO -> + AudioConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.REMOTE_HARDWARE -> - RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + RemoteHardwareConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.NEIGHBOR_INFO -> - NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + NeighborInfoConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.AMBIENT_LIGHTING -> - AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + AmbientLightingConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.DETECTION_SENSOR -> - DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + DetectionSensorConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.PAXCOUNTER -> + PaxcounterConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.STATUS_MESSAGE -> - StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + StatusMessageConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) ModuleRoute.TRAFFIC_MANAGEMENT -> - TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) - ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + TrafficManagementConfigScreen( + viewModel, + onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + ) + ModuleRoute.TAK -> + TAKConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } } entry { val viewModel: DebugViewModel = koinViewModel() - DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) + DebugScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } entry { - AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }, jsonProvider = { getAboutLibrariesJson() }) + AboutScreen( + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + jsonProvider = { getAboutLibrariesJson() }, + ) } entry { val viewModel: FilterSettingsViewModel = koinViewModel() - FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() }) + FilterSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index d47791300..26bacd139 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -17,10 +17,8 @@ package org.meshtastic.feature.settings.radio import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.nowSeconds @@ -31,6 +29,7 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.clean_node_database_confirmation import org.meshtastic.core.resources.clean_now import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.viewmodel.safeLaunch private const val MIN_DAYS_THRESHOLD = 7f @@ -65,7 +64,7 @@ class CleanNodeDatabaseViewModel( /** Updates the list of nodes to be deleted based on the current filter criteria. */ fun getNodesToDelete() { - viewModelScope.launch { + safeLaunch(tag = "getNodesToDelete") { _nodesToDelete.value = cleanNodeDatabaseUseCase.getNodesToClean( olderThanDays = _olderThanDays.value, @@ -76,7 +75,7 @@ class CleanNodeDatabaseViewModel( } fun requestCleanNodes() { - viewModelScope.launch { + safeLaunch(tag = "requestCleanNodes") { val count = _nodesToDelete.value.size val message = getString(Res.string.clean_node_database_confirmation, count) alertManager.showAlert( @@ -93,7 +92,7 @@ class CleanNodeDatabaseViewModel( * them. */ fun cleanNodes() { - viewModelScope.launch { + safeLaunch(tag = "cleanNodes") { val nodeNums = _nodesToDelete.value.map { it.num } cleanNodeDatabaseUseCase.cleanNodes(nodeNums) // Clear the list after deletion or if it was empty diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 592c15d3a..c59f00b56 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -20,9 +20,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -32,7 +34,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase @@ -44,6 +46,8 @@ import org.meshtastic.core.domain.usecase.settings.RadioResponseResult import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position @@ -53,6 +57,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.MapConsentPrefs +import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository @@ -62,6 +67,7 @@ import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.cant_shutdown import org.meshtastic.core.resources.timeout import org.meshtastic.core.ui.util.getChannelList +import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.proto.AdminMessage @@ -125,6 +131,7 @@ open class RadioConfigViewModel( private val processRadioResponseUseCase: ProcessRadioResponseUseCase, private val locationService: LocationService, private val fileService: FileService, + private val mqttManager: MqttManager, ) : ViewModel() { val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed @@ -138,6 +145,41 @@ open class RadioConfigViewModel( toggleHomoglyphEncodingUseCase() } + /** MQTT proxy connection state for the settings UI. */ + val mqttConnectionState: StateFlow = mqttManager.mqttConnectionState + + private val _mqttProbeStatus = MutableStateFlow(null) + + /** Latest result from a [probeMqttConnection] call, or `null` if no probe has been run. */ + val mqttProbeStatus: StateFlow = _mqttProbeStatus.asStateFlow() + + private var probeJob: Job? = null + + /** + * Run a one-shot reachability/credentials probe against an MQTT broker. Cancels any in-flight probe before starting + * a new one. Result is exposed via [mqttProbeStatus]. + */ + fun probeMqttConnection(address: String, tlsEnabled: Boolean, username: String?, password: String?) { + probeJob?.cancel() + _mqttProbeStatus.value = MqttProbeStatus.Probing + probeJob = + viewModelScope.launch { + val result = + runCatching { mqttManager.probe(address, tlsEnabled, username, password) } + .getOrElse { e -> + Logger.w(e) { "MQTT probe threw" } + MqttProbeStatus.Other(message = e.message) + } + _mqttProbeStatus.value = result + } + } + + /** Clear the latest probe result (e.g. when the user edits the address). */ + fun clearMqttProbeStatus() { + probeJob?.cancel() + _mqttProbeStatus.value = null + } + private val destNumFlow = MutableStateFlow(savedStateHandle.get("destNum")) fun initDestNum(id: Int?) { @@ -155,7 +197,7 @@ open class RadioConfigViewModel( val radioConfigState: StateFlow = _radioConfigState fun setPreserveFavorites(preserveFavorites: Boolean) { - viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } + _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } private val _currentDeviceProfile = MutableStateFlow(DeviceProfile()) @@ -242,7 +284,7 @@ open class RadioConfigViewModel( fun setOwner(user: User) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setOwner") { _radioConfigState.update { it.copy(userConfig = user) } val packetId = radioConfigUseCase.setOwner(destNum, user) registerRequestId(packetId) @@ -252,14 +294,14 @@ open class RadioConfigViewModel( fun updateChannels(new: List, old: List) { val destNum = destNode.value?.num ?: return getChannelList(new, old).forEach { channel -> - viewModelScope.launch { + safeLaunch(tag = "setRemoteChannel") { val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel) registerRequestId(packetId) } } if (destNum == myNodeNum) { - viewModelScope.launch { + safeLaunch(tag = "migrateChannels") { packetRepository.migrateChannelsByPSK(old, new) radioConfigRepository.replaceAllSettings(new) } @@ -269,7 +311,7 @@ open class RadioConfigViewModel( fun setConfig(config: Config) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setConfig") { _radioConfigState.update { state -> state.copy( radioConfig = @@ -293,7 +335,7 @@ open class RadioConfigViewModel( @Suppress("CyclomaticComplexMethod") fun setModuleConfig(config: ModuleConfig) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { + safeLaunch(tag = "setModuleConfig") { _radioConfigState.update { state -> state.copy( moduleConfig = @@ -326,13 +368,13 @@ open class RadioConfigViewModel( fun setRingtone(ringtone: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(ringtone = ringtone) } - viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) } + safeLaunch(tag = "setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) } } fun setCannedMessages(messages: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(cannedMessageMessages = messages) } - viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) } + safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) } } private fun sendAdminRequest(destNum: Int) { @@ -343,7 +385,7 @@ open class RadioConfigViewModel( when (route) { AdminRoute.REBOOT.name -> - viewModelScope.launch { + safeLaunch(tag = "reboot") { val packetId = adminActionsUseCase.reboot(destNum) registerRequestId(packetId) } @@ -352,7 +394,7 @@ open class RadioConfigViewModel( if (metadata?.canShutdown != true) { sendError(Res.string.cant_shutdown) } else { - viewModelScope.launch { + safeLaunch(tag = "shutdown") { val packetId = adminActionsUseCase.shutdown(destNum) registerRequestId(packetId) } @@ -360,13 +402,13 @@ open class RadioConfigViewModel( } AdminRoute.FACTORY_RESET.name -> - viewModelScope.launch { + safeLaunch(tag = "factoryReset") { val isLocal = (destNum == myNodeNum) val packetId = adminActionsUseCase.factoryReset(destNum, isLocal) registerRequestId(packetId) } AdminRoute.NODEDB_RESET.name -> - viewModelScope.launch { + safeLaunch(tag = "nodedbReset") { val isLocal = (destNum == myNodeNum) val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal) registerRequestId(packetId) @@ -376,55 +418,43 @@ open class RadioConfigViewModel( fun setFixedPosition(position: Position) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) } + safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) } } fun removeFixedPosition() { val destNum = destNode.value?.num ?: return - viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) } + safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) } } - fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) { - viewModelScope.launch { - try { - var profile: DeviceProfile? = null - fileService.read(uri) { source -> - importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } - } - profile?.let { onResult(it) } - } catch (ex: Exception) { - Logger.e { "Import DeviceProfile error: ${ex.message}" } + fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) { + safeLaunch(tag = "importProfile") { + var profile: DeviceProfile? = null + fileService.read(uri) { source -> + importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } + } + profile?.let { onResult(it) } + } + } + + fun exportProfile(uri: CommonUri, profile: DeviceProfile) { + safeLaunch(tag = "exportProfile") { + fileService.write(uri) { sink -> + exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } } } } - fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) { - viewModelScope.launch { - try { - fileService.write(uri) { sink -> - exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } - } - } catch (ex: Exception) { - Logger.e { "Can't write file error: ${ex.message}" } - } - } - } - - fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) { - viewModelScope.launch { - try { - fileService.write(uri) { sink -> - exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } - } - } catch (ex: Exception) { - Logger.e { "Can't write security keys JSON error: ${ex.message}" } + fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) { + safeLaunch(tag = "exportSecurityConfig") { + fileService.write(uri) { sink -> + exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } } } } fun installProfile(protobuf: DeviceProfile) { val destNum = destNode.value?.num ?: return - viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) } + safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) } } fun clearPacketResponse() { @@ -439,17 +469,17 @@ open class RadioConfigViewModel( when (route) { ConfigRoute.USER -> - viewModelScope.launch { + safeLaunch(tag = "getOwner") { val packetId = radioConfigUseCase.getOwner(destNum) registerRequestId(packetId) } ConfigRoute.CHANNELS -> { - viewModelScope.launch { + safeLaunch(tag = "getChannel0") { val packetId = radioConfigUseCase.getChannel(destNum, 0) registerRequestId(packetId) } - viewModelScope.launch { + safeLaunch(tag = "getLoraConfig") { val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value) registerRequestId(packetId) } @@ -458,7 +488,7 @@ open class RadioConfigViewModel( } is AdminRoute -> { - viewModelScope.launch { + safeLaunch(tag = "getSessionKeyConfig") { val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) registerRequestId(packetId) @@ -468,18 +498,18 @@ open class RadioConfigViewModel( is ConfigRoute -> { if (route == ConfigRoute.LORA) { - viewModelScope.launch { + safeLaunch(tag = "getChannel0ForLora") { val packetId = radioConfigUseCase.getChannel(destNum, 0) registerRequestId(packetId) } } if (route == ConfigRoute.NETWORK) { - viewModelScope.launch { + safeLaunch(tag = "getConnectionStatus") { val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum) registerRequestId(packetId) } } - viewModelScope.launch { + safeLaunch(tag = "getConfig") { val packetId = radioConfigUseCase.getConfig(destNum, route.type) registerRequestId(packetId) } @@ -487,18 +517,18 @@ open class RadioConfigViewModel( is ModuleRoute -> { if (route == ModuleRoute.CANNED_MESSAGE) { - viewModelScope.launch { + safeLaunch(tag = "getCannedMessages") { val packetId = radioConfigUseCase.getCannedMessages(destNum) registerRequestId(packetId) } } if (route == ModuleRoute.EXT_NOTIFICATION) { - viewModelScope.launch { + safeLaunch(tag = "getRingtone") { val packetId = radioConfigUseCase.getRingtone(destNum) registerRequestId(packetId) } } - viewModelScope.launch { + safeLaunch(tag = "getModuleConfig") { val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type) registerRequestId(packetId) } @@ -568,7 +598,7 @@ open class RadioConfigViewModel( } val requestTimeout = 30.seconds - viewModelScope.launch { + safeLaunch(tag = "requestTimeout") { delay(requestTimeout) if (requestIds.value.contains(packetId)) { requestIds.update { it.apply { remove(packetId) } } @@ -628,7 +658,7 @@ open class RadioConfigViewModel( val index = response.index if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { // Not done yet, request next channel - viewModelScope.launch { + safeLaunch(tag = "getNextChannel") { val packetId = radioConfigUseCase.getChannel(destNum, index + 1) registerRequestId(packetId) } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 650898747..885e64219 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -113,9 +113,9 @@ private fun ChannelConfigScreen( onPositiveClicked: (List) -> Unit, ) { val primarySettings = settingsList.getOrNull(0) ?: return - val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) } - val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) } - val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) } + val modemPresetName = remember(loraConfig) { Channel(loraConfig = loraConfig).name } + val primaryChannel = remember(loraConfig) { Channel(primarySettings, loraConfig) } + val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) } val focusManager = LocalFocusManager.current val settingsListInput = @@ -141,7 +141,7 @@ private fun ChannelConfigScreen( if (showEditChannelDialog != null) { val index = showEditChannelDialog ?: return EditChannelDialog( - channelSettings = with(settingsListInput) { if (size > index) get(index) else ChannelSettings() }, + channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(), modemPresetName = modemPresetName, onAddClick = { if (settingsListInput.size > index) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt index 0a943a70b..8c7386db5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt @@ -124,7 +124,7 @@ fun ChannelScreen( val modemPresetName by remember(channels) { mutableStateOf(Channel(loraConfig = channels.lora_config ?: Config.LoRaConfig()).name) } - var showResetDialog by remember { mutableStateOf(false) } + var showResetDialog by rememberSaveable { mutableStateOf(false) } var shouldAddChannelsState by remember { mutableStateOf(true) } @@ -211,7 +211,7 @@ fun ChannelScreen( requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) } - var showShareDialog by remember { mutableStateOf(false) } + var showShareDialog by rememberSaveable { mutableStateOf(false) } if (showShareDialog) { ChannelShareDialog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt index f73b6b731..8ec5f593e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.settings.radio.channel +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -29,7 +30,7 @@ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) { ChannelScreen( radioConfigViewModel = koinViewModel(), onNavigate = { route -> backStack.add(route) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -37,7 +38,7 @@ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) { ChannelScreen( radioConfigViewModel = koinViewModel(), onNavigate = { route -> backStack.add(route) }, - onNavigateUp = { backStack.removeLastOrNull() }, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt index c65cd971b..a614c1f99 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt @@ -59,6 +59,7 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.button_gpio import org.meshtastic.core.resources.buzzer_gpio import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.clear_time_zone import org.meshtastic.core.resources.config_device_doubleTapAsButtonPress_summary import org.meshtastic.core.resources.config_device_ledHeartbeatEnabled_summary import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summary @@ -269,7 +270,10 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit onValueChanged = { formState.value = formState.value.copy(tzdef = it) }, trailingIcon = { IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) { - Icon(imageVector = MeshtasticIcons.Close, contentDescription = null) + Icon( + imageVector = MeshtasticIcons.Close, + contentDescription = stringResource(Res.string.clear_time_zone), + ) } }, ) @@ -282,7 +286,10 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit shape = RectangleShape, onClick = { formState.value = formState.value.copy(tzdef = appTzPosixString) }, ) { - Icon(imageVector = MeshtasticIcons.PhoneAndroid, contentDescription = null) + Icon( + imageVector = MeshtasticIcons.PhoneAndroid, + contentDescription = stringResource(Res.string.config_device_use_phone_tz), + ) Spacer(modifier = Modifier.width(8.dp)) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index e4f91ece6..f57306799 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -71,7 +71,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val primarySettings = state.channelList.getOrNull(0) ?: return val formState = rememberConfigState(initialValue = loraConfig) - val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) } + val primaryChannel = remember(formState.value) { Channel(primarySettings, formState.value) } val focusManager = LocalFocusManager.current RadioConfigScreenList( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt index 8039dc37d..2646b20cb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.feature.settings.radio.ResponseState private const val LOADING_OVERLAY_ALPHA = 0.8f @@ -73,7 +73,7 @@ fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) { trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Text( - text = formatString("%.0f%%", progress * PERCENTAGE_FACTOR), + text = MetricFormatter.percent(progress * PERCENTAGE_FACTOR, decimalPlaces = 0), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt index 0427f9520..e1f407679 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt @@ -18,17 +18,37 @@ package org.meshtastic.feature.settings.radio.component +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.address import org.meshtastic.core.resources.default_mqtt_address @@ -38,6 +58,23 @@ import org.meshtastic.core.resources.map_reporting import org.meshtastic.core.resources.mqtt import org.meshtastic.core.resources.mqtt_config import org.meshtastic.core.resources.mqtt_enabled +import org.meshtastic.core.resources.mqtt_probe_dns_failure +import org.meshtastic.core.resources.mqtt_probe_other_failure +import org.meshtastic.core.resources.mqtt_probe_rejected +import org.meshtastic.core.resources.mqtt_probe_running +import org.meshtastic.core.resources.mqtt_probe_success +import org.meshtastic.core.resources.mqtt_probe_success_with_info +import org.meshtastic.core.resources.mqtt_probe_tcp_failure +import org.meshtastic.core.resources.mqtt_probe_timeout +import org.meshtastic.core.resources.mqtt_probe_tls_failure +import org.meshtastic.core.resources.mqtt_status_connected +import org.meshtastic.core.resources.mqtt_status_connecting +import org.meshtastic.core.resources.mqtt_status_disconnected +import org.meshtastic.core.resources.mqtt_status_disconnected_with_reason +import org.meshtastic.core.resources.mqtt_status_inactive +import org.meshtastic.core.resources.mqtt_status_reconnecting +import org.meshtastic.core.resources.mqtt_status_reconnecting_with_attempt +import org.meshtastic.core.resources.mqtt_test_connection import org.meshtastic.core.resources.password import org.meshtastic.core.resources.proxy_to_client_enabled import org.meshtastic.core.resources.root_topic @@ -54,6 +91,8 @@ import org.meshtastic.proto.ModuleConfig fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val destNode by viewModel.destNode.collectAsStateWithLifecycle() + val mqttProxyState by viewModel.mqttConnectionState.collectAsStateWithLifecycle() + val probeStatus by viewModel.mqttProbeStatus.collectAsStateWithLifecycle() val destNum = destNode?.num val mqttConfig = state.moduleConfig.mqtt ?: ModuleConfig.MQTTConfig() val formState = rememberConfigState(initialValue = mqttConfig) @@ -86,6 +125,8 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { viewModel.setModuleConfig(config) }, ) { + item { MqttStatusRow(mqttProxyState) } + item { TitledCard(title = stringResource(Res.string.mqtt_config)) { SwitchPreference( @@ -96,16 +137,13 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.address), - value = formState.value.address, - maxSize = 63, // address max_size:64 + MqttAddressAndProbe( enabled = state.connected, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(address = it) }, + formState = formState, + probeStatus = probeStatus, + focusManager = focusManager, + onProbe = viewModel::probeMqttConnection, + onClearProbe = viewModel::clearMqttProbeStatus, ) HorizontalDivider() EditTextPreference( @@ -210,3 +248,129 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } private const val MIN_INTERVAL_SECS = 3600 + +private val AmberColor = Color(0xFFFFA000) +private val GreenColor = Color(0xFF4CAF50) + +@Composable +private fun MqttStatusRow(state: MqttConnectionState) { + val (label, color) = + when (state) { + is MqttConnectionState.Inactive -> + stringResource(Res.string.mqtt_status_inactive) to MaterialTheme.colorScheme.outline + is MqttConnectionState.Disconnected -> { + val text = + state.reason?.let { stringResource(Res.string.mqtt_status_disconnected_with_reason, it) } + ?: stringResource(Res.string.mqtt_status_disconnected) + text to MaterialTheme.colorScheme.error + } + is MqttConnectionState.Connecting -> stringResource(Res.string.mqtt_status_connecting) to AmberColor + is MqttConnectionState.Connected -> stringResource(Res.string.mqtt_status_connected) to GreenColor + is MqttConnectionState.Reconnecting -> { + val err = state.lastError + val text = + if (err != null) { + stringResource(Res.string.mqtt_status_reconnecting_with_attempt, state.attempt, err) + } else { + stringResource(Res.string.mqtt_status_reconnecting) + } + text to AmberColor + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(color)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun MqttAddressAndProbe( + enabled: Boolean, + formState: ConfigState, + probeStatus: MqttProbeStatus?, + focusManager: FocusManager, + onProbe: (address: String, tlsEnabled: Boolean, username: String, password: String) -> Unit, + onClearProbe: () -> Unit, +) { + EditTextPreference( + title = stringResource(Res.string.address), + value = formState.value.address, + maxSize = 63, // address max_size:64 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + formState.value = formState.value.copy(address = it) + onClearProbe() + }, + ) + HorizontalDivider() + MqttProbeRow( + enabled = enabled && formState.value.address.isNotBlank(), + status = probeStatus, + onTestClick = { + focusManager.clearFocus() + onProbe( + formState.value.address, + formState.value.tls_enabled, + formState.value.username, + formState.value.password, + ) + }, + ) +} + +@Composable +private fun MqttProbeRow(enabled: Boolean, status: MqttProbeStatus?, onTestClick: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Button(onClick = onTestClick, enabled = enabled && status !is MqttProbeStatus.Probing) { + Text(stringResource(Res.string.mqtt_test_connection)) + } + val (probeText, probeColor) = status.toLabel() ?: return@Row + Text(text = probeText, style = MaterialTheme.typography.bodySmall, color = probeColor) + } + } +} + +@Composable +private fun MqttProbeStatus?.toLabel(): Pair? = when (this) { + null -> null + is MqttProbeStatus.Probing -> + stringResource(Res.string.mqtt_probe_running) to MaterialTheme.colorScheme.onSurfaceVariant + is MqttProbeStatus.Success -> { + val text = + serverInfo?.let { stringResource(Res.string.mqtt_probe_success_with_info, it) } + ?: stringResource(Res.string.mqtt_probe_success) + text to GreenColor + } + is MqttProbeStatus.Rejected -> + stringResource(Res.string.mqtt_probe_rejected, reason ?: reasonCode.toString()) to + MaterialTheme.colorScheme.error + is MqttProbeStatus.DnsFailure -> + stringResource(Res.string.mqtt_probe_dns_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.TcpFailure -> + stringResource(Res.string.mqtt_probe_tcp_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.TlsFailure -> + stringResource(Res.string.mqtt_probe_tls_failure) to MaterialTheme.colorScheme.error + is MqttProbeStatus.Timeout -> + stringResource(Res.string.mqtt_probe_timeout, timeoutMs.toInt()) to MaterialTheme.colorScheme.error + is MqttProbeStatus.Other -> + stringResource(Res.string.mqtt_probe_other_failure) to MaterialTheme.colorScheme.error +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt index 18d79e08f..c319c4f7f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.close @@ -111,7 +111,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) val progress by animateFloatAsState(targetValue = clampedProgress, label = "progress") Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = formatString("%.0f%%", progress * 100f), + text = MetricFormatter.percent(progress * 100f, decimalPlaces = 0), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.secondary, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt index 0e3c9058d..526bd63ef 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt @@ -30,6 +30,7 @@ import org.meshtastic.core.model.getColorFrom import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.export_tak_data_package import org.meshtastic.core.resources.tak import org.meshtastic.core.resources.tak_config import org.meshtastic.core.resources.tak_role @@ -74,7 +75,10 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onBack = onBack, actions = { IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) { - Icon(imageVector = MeshtasticIcons.Share, contentDescription = "Export TAK Data Package") + Icon( + imageVector = MeshtasticIcons.Share, + contentDescription = stringResource(Res.string.export_tak_data_package), + ) } }, configState = formState, diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 64eab2f80..0ba5c3a79 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase +import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase @@ -96,6 +97,7 @@ class SettingsViewModelTest { val uiPrefs = appPreferences.ui val setThemeUseCase = SetThemeUseCase(uiPrefs) + val setContrastLevelUseCase = SetContrastLevelUseCase(uiPrefs) val setLocaleUseCase = SetLocaleUseCase(uiPrefs) val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs) val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs) @@ -116,6 +118,7 @@ class SettingsViewModelTest { meshLogPrefs = appPreferences.meshLog, notificationPrefs = notificationPrefs, setThemeUseCase = setThemeUseCase, + setContrastLevelUseCase = setContrastLevelUseCase, setLocaleUseCase = setLocaleUseCase, setAppIntroCompletedUseCase = setAppIntroCompletedUseCase, setProvideLocationUseCase = setProvideLocationUseCase, diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt index f68a79f23..83bcddee1 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.dp import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_active_filters diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 167daebbf..c1b7d8a9e 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -53,6 +53,7 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.MapConsentPrefs +import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository @@ -99,6 +100,7 @@ class RadioConfigViewModelTest { private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mock(MockMode.autofill) private val locationService: LocationService = mock(MockMode.autofill) private val fileService: FileService = mock(MockMode.autofill) + private val mqttManager: MqttManager = mock(MockMode.autofill) private val uiPrefs: UiPrefs = mock(MockMode.autofill) private lateinit var viewModel: RadioConfigViewModel @@ -121,6 +123,9 @@ class RadioConfigViewModelTest { every { serviceRepository.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) + every { mqttManager.mqttConnectionState } returns + MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive) + every { uiPrefs.showQuickChat } returns MutableStateFlow(false) viewModel = createViewModel() @@ -152,6 +157,7 @@ class RadioConfigViewModelTest { processRadioResponseUseCase = processRadioResponseUseCase, locationService = locationService, fileService = fileService, + mqttManager = mqttManager, ) @Test diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt index 61d3b1219..cffeab006 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.getString diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt index 850cc93e7..42a67a6a0 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.i_agree diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt similarity index 100% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt rename to feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 9a221f8dd..2e358a58c 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -46,6 +46,7 @@ import org.meshtastic.core.resources.acknowledgements import org.meshtastic.core.resources.app_settings import org.meshtastic.core.resources.app_version import org.meshtastic.core.resources.bottom_nav_settings +import org.meshtastic.core.resources.contrast import org.meshtastic.core.resources.device_db_cache_limit import org.meshtastic.core.resources.device_db_cache_limit_summary import org.meshtastic.core.resources.info @@ -67,6 +68,7 @@ import org.meshtastic.core.ui.icon.Memory import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.core.ui.util.rememberShowToastResource +import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.NotificationSection @@ -101,6 +103,7 @@ fun DesktopSettingsScreen( var showThemePickerDialog by remember { mutableStateOf(false) } var showLanguagePickerDialog by remember { mutableStateOf(false) } + var showContrastPickerDialog by remember { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -108,6 +111,13 @@ fun DesktopSettingsScreen( ) } + if (showContrastPickerDialog) { + ContrastPickerDialog( + onClickContrast = { settingsViewModel.setContrastLevel(it) }, + onDismiss = { showContrastPickerDialog = false }, + ) + } + if (showLanguagePickerDialog) { LanguagePickerDialog( onSelectLanguage = { tag -> settingsViewModel.setLocale(tag) }, @@ -172,6 +182,14 @@ fun DesktopSettingsScreen( showThemePickerDialog = true } + ListItem( + text = stringResource(Res.string.contrast), + leadingIcon = MeshtasticIcons.FormatPaint, + trailingIcon = null, + ) { + showContrastPickerDialog = true + } + ListItem( text = stringResource(Res.string.preferences_language), leadingIcon = MeshtasticIcons.Language, diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 5b63cc90a..a9a728559 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr if (directory != null && file != null) { val targetFile = File(directory, file) val data = dataPackageProvider() - withContext(Dispatchers.IO) { targetFile.writeBytes(data) } + withContext(ioDispatcher) { targetFile.writeBytes(data) } Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } } } diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index 8d2045469..3054da6df 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -23,6 +23,7 @@ plugins { android { namespace = "org.meshtastic.feature.widget" + resourcePrefix = "widget_" defaultConfig { minSdk = 26 } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt index 415e0e11d..c6cef8aa3 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt @@ -17,22 +17,48 @@ package org.meshtastic.feature.widget import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.updateAll import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.repository.AppWidgetUpdater +private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L + @Single -class AndroidAppWidgetUpdater(private val context: Context) : AppWidgetUpdater { +class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) : + AppWidgetUpdater { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + init { + // Observe state changes and trigger a widget re-render whenever the data changes. + // Glance compositions are ephemeral — the widget cannot self-update via collectAsState() + // alone, so we must call updateAll() externally to drive re-renders. + @OptIn(FlowPreview::class) + scope.launch { + stateProvider.state + .debounce(WIDGET_UPDATE_DEBOUNCE_MS) + .distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) } + .collect { if (hasWidgetInstances()) updateAll() } + } + } + + private suspend fun hasWidgetInstances(): Boolean = + GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty() + override suspend fun updateAll() { - // Kickstart the widget composition. - // The widget internally uses collectAsState() and its own sampled StateFlow - // to drive updates automatically without excessive IPC and recreation. @Suppress("TooGenericExceptionCaught") try { LocalStatsWidget().updateAll(context) } catch (e: Exception) { - co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" } + Logger.e(e) { "Failed to update widgets" } } } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt index 6f988f2db..099b24cc3 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt @@ -132,11 +132,11 @@ class LocalStatsWidget : Scaffold( titleBar = { TitleBar( - startIcon = ImageProvider(R.drawable.app_icon), + startIcon = ImageProvider(R.drawable.widget_app_icon), title = stringResource(Res.string.meshtastic_app_name), actions = { CircleIconButton( - imageProvider = ImageProvider(R.drawable.ic_refresh), + imageProvider = ImageProvider(R.drawable.widget_ic_refresh), contentDescription = stringResource(Res.string.refresh), onClick = actionRunCallback(), backgroundColor = null, @@ -297,7 +297,7 @@ class LocalStatsWidget : CircularProgressIndicator(modifier = GlanceModifier.size(24.dp)) } else { Image( - provider = ImageProvider(R.drawable.app_icon), + provider = ImageProvider(R.drawable.widget_app_icon), contentDescription = null, modifier = GlanceModifier.size(32.dp), ) diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt index 793482ba2..b8aca2664 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt @@ -26,14 +26,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.LocalStats @@ -79,11 +77,7 @@ data class LocalStatsWidgetUiState( ) @Single -class LocalStatsWidgetStateProvider( - nodeRepository: NodeRepository, - serviceRepository: ServiceRepository, - appWidgetUpdater: AppWidgetUpdater, -) { +class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -104,8 +98,6 @@ class LocalStatsWidgetStateProvider( .map { input -> mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode) } - .distinctUntilChanged() - .onEach { appWidgetUpdater.updateAll() } .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState()) private data class StateInput( diff --git a/feature/widget/src/main/res/drawable/app_icon.xml b/feature/widget/src/main/res/drawable/widget_app_icon.xml similarity index 100% rename from feature/widget/src/main/res/drawable/app_icon.xml rename to feature/widget/src/main/res/drawable/widget_app_icon.xml diff --git a/feature/widget/src/main/res/drawable/ic_refresh.xml b/feature/widget/src/main/res/drawable/widget_ic_refresh.xml similarity index 100% rename from feature/widget/src/main/res/drawable/ic_refresh.xml rename to feature/widget/src/main/res/drawable/widget_ic_refresh.xml diff --git a/mesh_service_example/src/main/res/values/strings.xml b/feature/widget/src/main/res/values/strings.xml similarity index 87% rename from mesh_service_example/src/main/res/values/strings.xml rename to feature/widget/src/main/res/values/strings.xml index e194d4b9b..1e47c86ee 100644 --- a/mesh_service_example/src/main/res/values/strings.xml +++ b/feature/widget/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - - MeshServiceExample + Meshtastic diff --git a/feature/widget/src/main/res/xml/local_stats_widget_info.xml b/feature/widget/src/main/res/xml/widget_local_stats_info.xml similarity index 95% rename from feature/widget/src/main/res/xml/local_stats_widget_info.xml rename to feature/widget/src/main/res/xml/widget_local_stats_info.xml index da9863cd9..6dde1ea1e 100644 --- a/feature/widget/src/main/res/xml/local_stats_widget_info.xml +++ b/feature/widget/src/main/res/xml/widget_local_stats_info.xml @@ -16,6 +16,7 @@ ~ along with this program. If not, see . --> = runCatching { + suspend fun connect(address: String? = null): Result = safeCatching { Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" } val device = @@ -138,7 +138,7 @@ class NymeaWifiService( * * Sends: CMD_SCAN (4), waits for ack, then CMD_GET_NETWORKS (0). */ - suspend fun scanNetworks(): Result> = runCatching { + suspend fun scanNetworks(): Result> = safeCatching { // Trigger scan sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN))) val scanAck = NymeaJson.decodeFromString(waitForResponse()) @@ -180,7 +180,7 @@ class NymeaWifiService( NymeaConnectCommand(command = cmd, params = NymeaConnectParams(ssid = ssid, password = password)), ) - return runCatching { + return safeCatching { sendCommand(json) val response = NymeaJson.decodeFromString(waitForResponse()) if (response.responseCode == RESPONSE_SUCCESS) { diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt index ea30112c7..a79d32b25 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.wifiprovision.navigation +import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -31,9 +32,9 @@ import org.meshtastic.feature.wifiprovision.ui.WifiProvisionScreen */ fun EntryProviderScope.wifiProvisionGraph(backStack: NavBackStack) { entry { - WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }) + WifiProvisionScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) } entry { key -> - WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }, address = key.address) + WifiProvisionScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, address = key.address) } } diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt index 785654c71..397710fea 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -87,11 +88,13 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.action_select_network import org.meshtastic.core.resources.apply import org.meshtastic.core.resources.back import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.hide_password import org.meshtastic.core.resources.img_mpwrd_logo +import org.meshtastic.core.resources.mpwrd_os import org.meshtastic.core.resources.password import org.meshtastic.core.resources.show_password import org.meshtastic.core.resources.wifi_provision_available_networks @@ -488,7 +491,12 @@ internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () - } }, colors = ListItemDefaults.colors(containerColor = containerColor), - modifier = Modifier.clickable(onClick = onClick), + modifier = + Modifier.clickable( + onClickLabel = stringResource(Res.string.action_select_network), + role = Role.Button, + onClick = onClick, + ), ) } @@ -513,7 +521,7 @@ internal fun MpwrdDisclaimerBanner() { ) { Image( painter = painterResource(Res.drawable.img_mpwrd_logo), - contentDescription = "mPWRD-OS", + contentDescription = stringResource(Res.string.mpwrd_os), modifier = Modifier.size(MPWRD_LOGO_SIZE_DP.dp).clip(RoundedCornerShape(8.dp)), ) AutoLinkText( diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt index 65798a13b..0ee5bb0ec 100644 --- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.testing.FakeBleConnection import org.meshtastic.core.testing.FakeBleConnectionFactory import org.meshtastic.core.testing.FakeBleDevice @@ -62,7 +63,15 @@ class WifiProvisionViewModelTest { scanner = FakeBleScanner() connection = FakeBleConnection() viewModel = - WifiProvisionViewModel(bleScanner = scanner, bleConnectionFactory = FakeBleConnectionFactory(connection)) + WifiProvisionViewModel( + bleScanner = scanner, + bleConnectionFactory = FakeBleConnectionFactory(connection), + dispatchers = CoroutineDispatchers( + io = testDispatcher, + main = testDispatcher, + default = testDispatcher, + ), + ) } @AfterTest diff --git a/gradle.properties b/gradle.properties index 8e67ce164..2f265135a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,3 +29,4 @@ org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDu org.gradle.parallel=true org.gradle.vfs.watch=true org.gradle.welcome=never +compose.hot.reload=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 52d30d1ea..baf89fb1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ xmlutil = "0.91.3" # Android -agp = "9.2.0-alpha08" +agp = "9.2.0-rc01" appcompat = "1.7.1" accompanist = "0.37.3" @@ -12,14 +12,13 @@ glance = "1.2.0-rc01" lifecycle = "2.10.0" jetbrains-lifecycle = "2.11.0-alpha03" navigation3 = "1.1.0-rc01" -navigationevent = "1.1.0-alpha01" paging = "3.4.2" room = "3.0.0-alpha03" koin = "4.2.1" koin-plugin = "1.0.0-RC1" # Kotlin -kotlin = "2.3.21-RC" +kotlin = "2.3.21-RC2" kotlinx-coroutines-android = "1.10.2" kotlinx-datetime = "0.7.1" kotlinx-serialization = "1.11.0" @@ -36,6 +35,17 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" compose-multiplatform-material3 = "1.11.0-alpha06" +# `androidx-compose-bom-aligned` tracks androidx.compose.{runtime,ui} test/tracing +# artifacts that ship in lockstep with CMP. Kept as a separate version ref so Renovate +# can bump androidx releases (which often land first) without dragging the +# `org.jetbrains.compose:*` artifacts and Gradle plugin to a version JetBrains +# hasn't published yet (see PR #5180). Should normally match `compose-multiplatform`; +# AndroidCompose.kt's resolutionStrategy force-aligns these groups to the CMP version +# at resolution time regardless of the declared value here. +androidx-compose-bom-aligned = "1.11.0-rc01" +# `androidx-compose-material` (M2) is independent of CMP and pinned separately +# because some third-party libs (maps-compose-widgets, datadog) drag in +# unversioned material transitives. androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" @@ -56,7 +66,7 @@ aboutlibraries = "14.0.1" jserialcomm = "2.11.4" coil = "3.4.0" datadog-gradle = "1.25.0" -dd-sdk-android = "3.8.0" +dd-sdk-android = "3.9.0" detekt = "1.23.8" dokka = "2.2.0" devtools-ksp = "2.3.6" @@ -64,12 +74,13 @@ firebase-crashlytics-gradle = "3.0.7" google-services-gradle = "4.4.4" markdownRenderer = "0.40.2" okio = "3.17.0" +uri-kmp = "0.0.21" osmdroid-android = "6.1.20" spotless = "8.4.0" wire = "6.2.0" -vico = "3.1.0" +vico = "3.2.0-next.1" kable = "0.42.0" -kmqtt = "1.0.0" +mqttastic = "0.2.0" jmdns = "3.6.3" qrcode-kotlin = "4.5.0" @@ -100,14 +111,12 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi # lifecycle-runtime-ktx dropped: KTX extensions merged into lifecycle-runtime since 2.8.0; # use jetbrains-lifecycle-runtime (JB KMP fork) instead. androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } -androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" } # JetBrains KMP lifecycle (use in commonMain and androidMain) jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } -jetbrains-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" } androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" } @@ -119,9 +128,8 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } # AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) -androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose-material" } # Only used by deprecated mesh_service_example — remove when that module is deleted -androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose-multiplatform" } -androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-multiplatform" } # Required by Robolectric Compose tests (registers ComponentActivity) +androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-bom-aligned" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-bom-aligned" } # Required by Robolectric Compose tests (registers ComponentActivity) # Compose Multiplatform compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" } @@ -147,7 +155,6 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.12.0 firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } location-services = { module = "com.google.android.gms:play-services-location", version = "21.3.0" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } -koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } @@ -205,7 +212,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } -dd-sdk-android-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dd-sdk-android" } + dd-sdk-android-logs = { module = "com.datadoghq:dd-sdk-android-logs", version.ref = "dd-sdk-android" } dd-sdk-android-rum = { module = "com.datadoghq:dd-sdk-android-rum", version.ref = "dd-sdk-android" } dd-sdk-android-session-replay = { module = "com.datadoghq:dd-sdk-android-session-replay", version.ref = "dd-sdk-android" } @@ -220,11 +227,11 @@ markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-rend material = { module = "com.google.android.material:material", version = "1.13.0" } kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" } -kmqtt-client = { module = "io.github.davidepianca98:kmqtt-client", version.ref = "kmqtt" } -kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref = "kmqtt" } +meshtastic-mqtt-client = { module = "org.meshtastic:mqtt-client", version.ref = "mqttastic" } jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +uri-kmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" } osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" } osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" } osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" } @@ -236,7 +243,7 @@ vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ve # Build Logic android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" } -android-tools-common = { module = "com.android.tools:common", version = "32.1.0" } +android-tools-common = { module = "com.android.tools:common", version = "32.1.1" } androidx-room-gradlePlugin = { module = "androidx.room3:room3-gradle-plugin", version.ref = "room" } compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } compose-multiplatform-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" } @@ -250,7 +257,6 @@ koin-gradlePlugin = { module = "io.insert-koin.compiler.plugin:io.insert-koin.co mokkery-gradlePlugin = { module = "dev.mokkery:mokkery-gradle", version.ref = "mokkery" } kover-gradlePlugin = { module = "org.jetbrains.kotlinx.kover:org.jetbrains.kotlinx.kover.gradle.plugin", version.ref = "kover" } ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "devtools-ksp" } -secrets-gradlePlugin = {module = "com.google.android.secrets-gradle-plugin:com.google.android.secrets-gradle-plugin.gradle.plugin", version = "1.1.0"} serialization-gradlePlugin = { module = "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin", version.ref = "kotlin" } spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" } @@ -293,14 +299,12 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" } # Meshtastic -meshtastic-analytics = { id = "meshtastic.analytics" } meshtastic-android-application = { id = "meshtastic.android.application" } meshtastic-android-application-compose = { id = "meshtastic.android.application.compose" } meshtastic-android-application-flavors = { id = "meshtastic.android.application.flavors" } meshtastic-android-library = { id = "meshtastic.android.library" } meshtastic-android-library-compose = { id = "meshtastic.android.library.compose" } meshtastic-android-library-flavors = { id = "meshtastic.android.library.flavors" } -meshtastic-android-lint = { id = "meshtastic.android.lint" } meshtastic-android-room = { id = "meshtastic.android.room" } meshtastic-detekt = { id = "meshtastic.detekt" } meshtastic-koin = { id = "meshtastic.koin" } diff --git a/mesh_service_example/README.md b/mesh_service_example/README.md deleted file mode 100644 index 3804db328..000000000 --- a/mesh_service_example/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# mesh_service_example - -> **DEPRECATED — scheduled for removal in a future release.** -> -> This module is no longer maintained and will be deleted once the new public API documentation is -> available. Do not add new code here. Do not use it as a template for new integrations. -> -> For integrating with the Meshtastic service from your own app, refer to the `:core:api` module -> README at [`core/api/README.md`](../core/api/README.md). - -## What this was - -`mesh_service_example` was a sample Android application demonstrating how to bind to the -`IMeshService` AIDL interface and exchange data with the Meshtastic radio service. It is kept in -the repository only to avoid breaking the CI assemble task (`mesh_service_example:assembleDebug`) -and the JitPack publication that consumers may reference, until those are formally retired. - -## License - -See the root `LICENSE` file. diff --git a/mesh_service_example/build.gradle.kts b/mesh_service_example/build.gradle.kts deleted file mode 100644 index 793735dda..000000000 --- a/mesh_service_example/build.gradle.kts +++ /dev/null @@ -1,54 +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 . - */ - -import com.android.build.api.dsl.ApplicationExtension -import org.meshtastic.buildlogic.FlavorDimension -import org.meshtastic.buildlogic.MeshtasticFlavor - -plugins { - alias(libs.plugins.meshtastic.android.application) - alias(libs.plugins.meshtastic.android.application.compose) - alias(libs.plugins.kotlin.parcelize) - alias(libs.plugins.kotlin.serialization) -} - -configure { - namespace = "com.meshtastic.android.meshserviceexample" - defaultConfig { - // Force this app to use the Google variant of any modules it's using that apply AndroidLibraryConventionPlugin - missingDimensionStrategy(FlavorDimension.marketplace.name, MeshtasticFlavor.google.name) - } - - testOptions { unitTests.isReturnDefaultValues = true } -} - -dependencies { - implementation(projects.core.api) - implementation(projects.core.model) - implementation(projects.core.proto) - - implementation(libs.androidx.activity.compose) - implementation(libs.jetbrains.lifecycle.viewmodel.compose) - implementation(libs.jetbrains.lifecycle.runtime) - implementation(libs.compose.multiplatform.material3) - implementation(libs.androidx.compose.material.iconsExtended) - implementation(libs.material) - - testImplementation(libs.junit) - testRuntimeOnly(libs.junit.vintage.engine) - testImplementation(libs.kotlinx.coroutines.test) -} diff --git a/mesh_service_example/detekt-baseline.xml b/mesh_service_example/detekt-baseline.xml deleted file mode 100644 index ecf2e0cce..000000000 --- a/mesh_service_example/detekt-baseline.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/mesh_service_example/proguard-rules.pro b/mesh_service_example/proguard-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/mesh_service_example/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# 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 *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/mesh_service_example/src/main/AndroidManifest.xml b/mesh_service_example/src/main/AndroidManifest.xml deleted file mode 100644 index b8ffa4cae..000000000 --- a/mesh_service_example/src/main/AndroidManifest.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt deleted file mode 100644 index d61c6f192..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt +++ /dev/null @@ -1,187 +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 . - */ -@file:Suppress("DEPRECATION") - -package com.meshtastic.android.meshserviceexample - -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.ServiceConnection -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import org.meshtastic.core.api.MeshtasticIntent -import org.meshtastic.core.service.IMeshService - -private const val TAG: String = "MeshServiceExample" - -/** - * MainActivity for the MeshServiceExample application. - * - * **DEPRECATED.** This entire module (`mesh_service_example`) is scheduled for removal in a future release. Do not use - * it as a template for new integrations. See `:core:api` README for the current public API surface. - */ -@Deprecated( - message = - "mesh_service_example is deprecated and will be removed in a future release. " + - "See core/api/README.md for integration guidance.", -) -class MainActivity : ComponentActivity() { - - private var meshService: IMeshService? = null - private var isMeshServiceBound = false - - private val viewModel: MeshServiceViewModel by viewModels() - - private val serviceConnection = - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - meshService = IMeshService.Stub.asInterface(service) - Log.i(TAG, "Connected to MeshService") - isMeshServiceBound = true - viewModel.onServiceConnected(meshService) - } - - override fun onServiceDisconnected(name: ComponentName?) { - meshService = null - isMeshServiceBound = false - viewModel.onServiceDisconnected() - } - } - - private val meshtasticReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - Log.d(TAG, "BroadcastReceiver onReceive: ${intent?.action}") - intent?.let { viewModel.handleIncomingIntent(it) } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - - bindMeshService() - - val intentFilter = - IntentFilter().apply { - addAction(MeshtasticIntent.ACTION_NODE_CHANGE) - addAction(MeshtasticIntent.ACTION_CONNECTION_CHANGED) - addAction(MeshtasticIntent.ACTION_MESH_CONNECTED) - addAction(MeshtasticIntent.ACTION_MESH_DISCONNECTED) - addAction(MeshtasticIntent.ACTION_MESSAGE_STATUS) - addAction(MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_POSITION_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_TELEMETRY_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_NODEINFO_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_PLUGIN) - addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_FORWARDER) - addAction(MeshtasticIntent.ACTION_RECEIVED_DETECTION_SENSOR_APP) - addAction(MeshtasticIntent.ACTION_RECEIVED_PRIVATE_APP) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(meshtasticReceiver, intentFilter, RECEIVER_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") - registerReceiver(meshtasticReceiver, intentFilter) - } - - setContent { ExampleTheme { MainScreen(viewModel) } } - } - - override fun onDestroy() { - super.onDestroy() - unregisterReceiver(meshtasticReceiver) - unbindMeshService() - } - - private fun bindMeshService() { - try { - Log.i(TAG, "Attempting to bind to Mesh Service...") - val intent = Intent("com.geeksville.mesh.Service") - - val resolveInfo = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.queryIntentServices(intent, PackageManager.ResolveInfoFlags.of(0)) - } else { - @Suppress("DEPRECATION") - packageManager.queryIntentServices(intent, 0) - } - - if (resolveInfo.isNotEmpty()) { - val serviceInfo = resolveInfo[0].serviceInfo - intent.setClassName(serviceInfo.packageName, serviceInfo.name) - Log.i(TAG, "Found service in package: ${serviceInfo.packageName}") - } else { - Log.w(TAG, "No service found for action com.geeksville.mesh.Service. Falling back to default.") - intent.setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") - } - - val success = bindService(intent, serviceConnection, BIND_AUTO_CREATE) - if (!success) { - Log.e(TAG, "bindService returned false") - } - } catch (e: SecurityException) { - Log.e(TAG, "SecurityException while binding: ${e.message}") - } - } - - private fun unbindMeshService() { - if (isMeshServiceBound) { - try { - unbindService(serviceConnection) - } catch (e: IllegalArgumentException) { - Log.w(TAG, "MeshService not registered or already unbound: ${e.message}") - } - isMeshServiceBound = false - meshService = null - } - } -} - -@Composable -fun ExampleTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colorScheme = - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> darkColorScheme() - else -> lightColorScheme() - } - - MaterialTheme(colorScheme = colorScheme, content = content) -} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt deleted file mode 100644 index 408a37d25..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt +++ /dev/null @@ -1,585 +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 . - */ -@file:Suppress("TooManyFunctions") - -package com.meshtastic.android.meshserviceexample - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Message -import androidx.compose.material.icons.automirrored.rounded.Send -import androidx.compose.material.icons.rounded.AccountCircle -import androidx.compose.material.icons.rounded.BatteryUnknown -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.GpsFixed -import androidx.compose.material.icons.rounded.GpsOff -import androidx.compose.material.icons.rounded.Hub -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.MyLocation -import androidx.compose.material.icons.rounded.PersonSearch -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material.icons.rounded.Route -import androidx.compose.material.icons.rounded.Router -import androidx.compose.material.icons.rounded.SignalCellularAlt -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.proto.PortNum - -@Composable -fun ListItem( - text: String, - supportingText: String? = null, - leadingIcon: ImageVector? = null, - trailingIcon: ImageVector? = null, -) { - androidx.compose.material3.ListItem( - headlineContent = { Text(text) }, - supportingContent = supportingText?.let { { Text(it) } }, - leadingContent = leadingIcon?.let { { Icon(it, contentDescription = null) } }, - trailingContent = trailingIcon?.let { { Icon(it, contentDescription = null) } }, - ) -} - -@Composable -fun TitledCard(title: String, content: @Composable () -> Unit) { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 12.dp), - ) - content() - } - } -} - -@Composable -fun SectionHeader(title: String, expanded: Boolean, onExpandClick: () -> Unit, modifier: Modifier = Modifier) { - Card( - modifier = modifier.fillMaxWidth().clickable { onExpandClick() }, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) - Icon( - imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, - contentDescription = if (expanded) "Collapse" else "Expand", - tint = MaterialTheme.colorScheme.primary, - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MainScreen(viewModel: MeshServiceViewModel) { - val isConnected by viewModel.serviceConnectionStatus.collectAsState() - val connectionState by viewModel.connectionState.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - - Scaffold( - modifier = Modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopAppBar( - title = { TopBarTitle(isConnected, connectionState) }, - actions = { - IconButton( - onClick = { - viewModel.requestNodes() - scope.launch { snackbarHostState.showSnackbar("Refreshing nodes...") } - }, - ) { - Icon(Icons.Rounded.Refresh, contentDescription = "Refresh Nodes") - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - ), - ) - }, - ) { innerPadding -> - MainContent(viewModel, innerPadding, snackbarHostState) - } -} - -@Composable -private fun TopBarTitle(isConnected: Boolean, connectionState: String) { - Column { - Text( - text = "Mesh Service Example", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleLarge, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - val statusColor = - if (isConnected) { - Color.Green - } else { - MaterialTheme.colorScheme.error - } - Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(statusColor)) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (isConnected) "Connected ($connectionState)" else "Disconnected", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -@Suppress("LongMethod") -private fun MainContent( - viewModel: MeshServiceViewModel, - innerPadding: PaddingValues, - snackbarHostState: SnackbarHostState, -) { - val myNodeInfo by viewModel.myNodeInfo.collectAsState() - val myId by viewModel.myId.collectAsState() - val nodes by viewModel.nodes.collectAsState() - val lastMessage by viewModel.message.collectAsState() - val packetLog by viewModel.packetLog.collectAsState() - - var nodesExpanded by remember { mutableStateOf(false) } - var logExpanded by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - LazyColumn( - modifier = Modifier.padding(innerPadding).fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - item { MyInfoSection(myId, myNodeInfo) } - item { TitledCard(title = "Messaging") { MessagingSection(viewModel, lastMessage) } } - item { TitledCard(title = "Test Special PortNums") { SpecialAppSection(viewModel) } } - - item { - SectionHeader( - title = "Mesh Nodes (${nodes.size})", - expanded = nodesExpanded, - onExpandClick = { nodesExpanded = !nodesExpanded }, - ) - } - - if (nodesExpanded) { - if (nodes.isEmpty()) { - item { EmptyNodeState() } - } else { - items(nodes) { node -> - Card(modifier = Modifier.fillMaxWidth()) { - val nodeLabel = node.user?.longName ?: node.user?.id ?: "Unknown Node" - NodeItem(node) { action -> - scope.launch { - when (action) { - "traceroute" -> { - viewModel.requestTraceroute(node.num) - snackbarHostState.showSnackbar("Traceroute requested for $nodeLabel") - } - "telemetry" -> { - viewModel.requestTelemetry(node.num) - snackbarHostState.showSnackbar("Telemetry requested for $nodeLabel") - } - "neighbors" -> { - viewModel.requestNeighborInfo(node.num) - snackbarHostState.showSnackbar("Neighbor info requested for $nodeLabel") - } - "position" -> { - viewModel.requestPosition(node.num) - snackbarHostState.showSnackbar("Position requested for $nodeLabel") - } - "userinfo" -> { - viewModel.requestUserInfo(node.num) - snackbarHostState.showSnackbar("User info requested for $nodeLabel") - } - "connstatus" -> { - viewModel.requestDeviceConnectionStatus(node.num) - snackbarHostState.showSnackbar("Connection status requested for $nodeLabel") - } - } - } - } - } - } - } - } - - item { - SectionHeader(title = "Packet Log", expanded = logExpanded, onExpandClick = { logExpanded = !logExpanded }) - } - - if (logExpanded) { - item { - Card(modifier = Modifier.fillMaxWidth()) { - Box(modifier = Modifier.padding(16.dp)) { PacketLogContent(packetLog) } - } - } - } - - item { ActionButtons(viewModel, snackbarHostState) } - item { Spacer(modifier = Modifier.height(16.dp)) } - } -} - -@Composable -fun SpecialAppSection(viewModel: MeshServiceViewModel) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { viewModel.sendSpecialPacket(PortNum.ATAK_PLUGIN) }, modifier = Modifier.weight(1f)) { - Text("Send ATAK") - } - Button( - onClick = { viewModel.sendSpecialPacket(PortNum.DETECTION_SENSOR_APP) }, - modifier = Modifier.weight(1f), - ) { - Text("Send Sensor") - } - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { viewModel.sendSpecialPacket(PortNum.PRIVATE_APP) }, modifier = Modifier.weight(1f)) { - Text("Send Private") - } - } - } -} - -@Composable -private fun PacketLogContent(log: List) { - Column(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp).verticalScroll(rememberScrollState())) { - if (log.isEmpty()) { - Text( - text = "No packets yet.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) - } else { - log.forEach { entry -> - Text( - text = entry, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(vertical = 2.dp), - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - } - } - } -} - -@Composable -private fun MyInfoSection(myId: String?, myNodeInfo: org.meshtastic.core.model.MyNodeInfo?) { - TitledCard(title = "My Node Information") { - ListItem( - text = "Long ID", - supportingText = myId ?: "N/A", - leadingIcon = Icons.Rounded.AccountCircle, - trailingIcon = null, - ) - ListItem( - text = "Firmware", - supportingText = myNodeInfo?.firmwareString ?: "N/A", - leadingIcon = Icons.Rounded.Info, - trailingIcon = null, - ) - } -} - -@Composable -private fun EmptyNodeState() { - Text( - text = "No mesh nodes discovered yet.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), - textAlign = TextAlign.Center, - ) -} - -@Composable -fun MessagingSection(viewModel: MeshServiceViewModel, lastMessage: String) { - var textToSend by remember { mutableStateOf("") } - - Column(modifier = Modifier.padding(16.dp)) { - if (lastMessage.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - ), - ) { - ListItem( - text = "Last Received", - supportingText = lastMessage, - leadingIcon = Icons.AutoMirrored.Rounded.Message, - trailingIcon = null, - ) - } - Spacer(modifier = Modifier.height(12.dp)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = textToSend, - onValueChange = { textToSend = it }, - modifier = Modifier.weight(1f), - label = { Text("Send broadcast message") }, - shape = MaterialTheme.shapes.large, - ) - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { - if (textToSend.isNotBlank()) { - viewModel.sendMessage(textToSend) - textToSend = "" - } - }, - modifier = Modifier.size(56.dp), - shape = MaterialTheme.shapes.large, - contentPadding = PaddingValues(0.dp), - ) { - Icon(imageVector = Icons.AutoMirrored.Rounded.Send, contentDescription = "Send") - } - } - } -} - -@Composable -fun NodeItem(node: NodeInfo, onAction: (String) -> Unit) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - NodeItemHeader(node) - Spacer(modifier = Modifier.height(8.dp)) - NodeItemActions(node.isOnline, onAction) - } -} - -@Composable -private fun NodeItemHeader(node: NodeInfo) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(contentAlignment = Alignment.BottomEnd) { - Icon( - imageVector = Icons.Rounded.AccountCircle, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.outline, - ) - if (node.isOnline) { - Box( - modifier = - Modifier.size(14.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - .padding(2.dp) - .clip(CircleShape) - .background(Color.Green), - ) - } - } - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = node.user?.longName ?: "Unknown Node", - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = "ID: ${node.user?.id ?: "N/A"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -private fun NodeItemActions(isOnline: Boolean, onAction: (String) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = { onAction("traceroute") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.Route, "Traceroute", Modifier.size(20.dp), MaterialTheme.colorScheme.primary) - } - IconButton(onClick = { onAction("telemetry") }, modifier = Modifier.size(40.dp)) { - Icon( - @Suppress("DEPRECATION") // AutoMirrored variant not available in current icons version - Icons.Rounded.BatteryUnknown, - "Telemetry", - Modifier.size(20.dp), - MaterialTheme.colorScheme.secondary, - ) - } - IconButton(onClick = { onAction("position") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.MyLocation, "Position", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary) - } - IconButton(onClick = { onAction("neighbors") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.Hub, "Neighbors", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary) - } - IconButton(onClick = { onAction("userinfo") }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Rounded.PersonSearch, "User Info", Modifier.size(20.dp), MaterialTheme.colorScheme.outline) - } - IconButton(onClick = { onAction("connstatus") }, modifier = Modifier.size(40.dp)) { - Icon( - Icons.Rounded.SignalCellularAlt, - "Conn Status", - Modifier.size(20.dp), - MaterialTheme.colorScheme.outline, - ) - } - if (isOnline) { - Icon( - imageVector = Icons.Rounded.Router, - contentDescription = "Online", - tint = androidx.compose.ui.graphics.Color.Green.copy(alpha = 0.5f), - modifier = Modifier.padding(start = 8.dp).size(20.dp), - ) - } - } -} - -@Composable -private fun ActionButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) { - val scope = rememberCoroutineScope() - TitledCard(title = "Device Controls") { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - GpsButtons(viewModel, snackbarHostState) - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - viewModel.rebootLocalDevice() - scope.launch { snackbarHostState.showSnackbar("Reboot Requested") } - }, - shape = MaterialTheme.shapes.medium, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Icon(imageVector = Icons.Rounded.RestartAlt, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Reboot Radio") - } - } - } -} - -@Composable -private fun GpsButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) { - val scope = rememberCoroutineScope() - val colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - modifier = Modifier.weight(1f), - onClick = { - viewModel.startProvideLocation() - scope.launch { snackbarHostState.showSnackbar("GPS Sharing Started") } - }, - shape = MaterialTheme.shapes.medium, - colors = colors, - ) { - Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Start GPS", style = MaterialTheme.typography.labelLarge) - } - Button( - modifier = Modifier.weight(1f), - onClick = { - viewModel.stopProvideLocation() - scope.launch { snackbarHostState.showSnackbar("GPS Sharing Stopped") } - }, - shape = MaterialTheme.shapes.medium, - colors = colors, - ) { - Icon(imageVector = Icons.Rounded.GpsOff, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Stop GPS", style = MaterialTheme.typography.labelLarge) - } - } -} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt deleted file mode 100644 index 7c72516bf..000000000 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt +++ /dev/null @@ -1,363 +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 . - */ -@file:Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding - -package com.meshtastic.android.meshserviceexample - -import android.content.Intent -import android.os.Build -import android.os.Parcelable -import android.os.RemoteException -import android.util.Log -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.toDate -import org.meshtastic.core.common.util.toInstant -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.service.IMeshService -import org.meshtastic.proto.PortNum -import java.text.SimpleDateFormat -import java.util.Locale -import kotlin.random.Random - -private const val TAG = "MeshServiceViewModel" - -/** ViewModel for MeshServiceExample. Handles interaction with IMeshService AIDL and manages UI state. */ -@Suppress("TooManyFunctions") -class MeshServiceViewModel : ViewModel() { - - private var meshService: IMeshService? = null - - private val _myNodeInfo = MutableStateFlow(null) - val myNodeInfo: StateFlow = _myNodeInfo.asStateFlow() - - private val _myId = MutableStateFlow(null) - val myId: StateFlow = _myId.asStateFlow() - - private val _nodes = MutableStateFlow>(emptyList()) - val nodes: StateFlow> = _nodes.asStateFlow() - - private val _serviceConnectionStatus = MutableStateFlow(false) - val serviceConnectionStatus: StateFlow = _serviceConnectionStatus.asStateFlow() - - private val _message = MutableStateFlow("") - val message: StateFlow = _message.asStateFlow() - - private val _connectionState = MutableStateFlow("UNKNOWN") - val connectionState: StateFlow = _connectionState.asStateFlow() - - private val _packetLog = MutableStateFlow>(emptyList()) - val packetLog: StateFlow> = _packetLog.asStateFlow() - - fun onServiceConnected(service: IMeshService?) { - meshService = service - _serviceConnectionStatus.value = true - updateAllData() - addToLog("Service Connected") - } - - fun onServiceDisconnected() { - meshService = null - _serviceConnectionStatus.value = false - addToLog("Service Disconnected") - } - - private fun updateAllData() { - requestMyNodeInfo() - requestNodes() - updateConnectionState() - updateMyId() - } - - fun updateMyId() { - meshService?.let { - try { - _myId.value = it.myId - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get MyId", e) - } - } - } - - fun updateConnectionState() { - meshService?.let { - try { - val state = it.connectionState() ?: "UNKNOWN" - _connectionState.value = state - addToLog("Connection State: $state") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get connection state", e) - } - } - } - - fun sendMessage(text: String) { - meshService?.let { service -> - try { - val packet = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = text.encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - from = DataPacket.ID_LOCAL, - time = nowMillis, - id = service.packetId, - status = MessageStatus.UNKNOWN, - hopLimit = 3, - channel = 0, - wantAck = true, - ) - service.send(packet) - Log.d(TAG, "Message sent successfully, assigned ID: ${packet.id}") - addToLog("Sent: $text (ID: ${packet.id})") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to send message", e) - addToLog("Failed to send message: ${e.message}") - } - } ?: Log.w(TAG, "MeshService is not bound, cannot send message") - } - - fun sendSpecialPacket(portNum: PortNum) { - meshService?.let { service -> - try { - val packet = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = "Special Payload for ${portNum.name}".encodeToByteArray().toByteString(), - dataType = portNum.value, - from = DataPacket.ID_LOCAL, - time = nowMillis, - id = service.packetId, - status = MessageStatus.UNKNOWN, - hopLimit = 3, - channel = 0, - wantAck = true, - ) - service.send(packet) - addToLog("Sent ${portNum.name} Packet (ID: ${packet.id})") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to send special packet", e) - addToLog("Failed to send ${portNum.name} packet: ${e.message}") - } - } - } - - fun requestMyNodeInfo() { - meshService?.let { - try { - _myNodeInfo.value = it.myNodeInfo - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get MyNodeInfo", e) - } - } - } - - fun requestNodes() { - meshService?.let { - try { - _nodes.value = it.nodes ?: emptyList() - } catch (e: RemoteException) { - Log.e(TAG, "Failed to get nodes", e) - } - } - } - - fun startProvideLocation() { - try { - meshService?.startProvideLocation() - addToLog("Started GPS sharing") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to start providing location", e) - } - } - - fun stopProvideLocation() { - try { - meshService?.stopProvideLocation() - addToLog("Stopped GPS sharing") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to stop providing location", e) - } - } - - fun requestTraceroute(nodeNum: Int) { - meshService?.let { - try { - it.requestTraceroute(Random.nextInt(), nodeNum) - Log.i(TAG, "Traceroute requested for node $nodeNum") - addToLog("Requested Traceroute for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request traceroute", e) - } - } - } - - fun requestTelemetry(nodeNum: Int) { - meshService?.let { - try { - it.requestTelemetry(Random.nextInt(), nodeNum, 1) - Log.i(TAG, "Telemetry requested for node $nodeNum") - addToLog("Requested Telemetry for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request telemetry", e) - } - } - } - - fun requestNeighborInfo(nodeNum: Int) { - meshService?.let { - try { - it.requestNeighborInfo(Random.nextInt(), nodeNum) - Log.i(TAG, "Neighbor info requested for node $nodeNum") - addToLog("Requested Neighbors for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request neighbor info", e) - } - } - } - - fun requestPosition(nodeNum: Int) { - meshService?.let { - try { - it.requestPosition(nodeNum, Position(0.0, 0.0, 0)) - Log.i(TAG, "Position requested for node $nodeNum") - addToLog("Requested Position for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request position", e) - } - } - } - - fun requestUserInfo(nodeNum: Int) { - meshService?.let { - try { - it.requestUserInfo(nodeNum) - Log.i(TAG, "User info requested for node $nodeNum") - addToLog("Requested User Info for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request user info", e) - } - } - } - - fun requestDeviceConnectionStatus(nodeNum: Int) { - meshService?.let { - try { - it.getDeviceConnectionStatus(Random.nextInt(), nodeNum) - Log.i(TAG, "Device connection status requested for node $nodeNum") - addToLog("Requested Connection Status for $nodeNum") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request device connection status", e) - } - } - } - - fun rebootLocalDevice() { - meshService?.let { - try { - it.requestReboot(Random.nextInt(), 0) - Log.w(TAG, "Local reboot requested!") - addToLog("Requested Local Reboot") - } catch (e: RemoteException) { - Log.e(TAG, "Failed to request reboot", e) - } - } - } - - fun handleIncomingIntent(intent: Intent) { - val action = intent.action ?: return - Log.d(TAG, "Received broadcast: $action") - - when (action) { - "com.geeksville.mesh.NODE_CHANGE" -> handleNodeChange(intent) - "com.geeksville.mesh.CONNECTION_CHANGED", - "com.geeksville.mesh.MESH_CONNECTED", - "com.geeksville.mesh.MESH_DISCONNECTED", - -> updateConnectionState() - - "com.geeksville.mesh.MESSAGE_STATUS" -> handleMessageStatus(intent) - else -> - if (action.startsWith("com.geeksville.mesh.RECEIVED.")) { - handleReceivedPacket(action, intent) - } - } - } - - private fun handleNodeChange(intent: Intent) { - val nodeInfo = intent.getParcelableCompat("com.geeksville.mesh.NodeInfo", NodeInfo::class.java) - nodeInfo?.let { ni -> - Log.d(TAG, "Node updated: ${ni.num}") - _nodes.value = - _nodes.value.toMutableList().apply { - val index = indexOfFirst { it.num == ni.num } - if (index != -1) set(index, ni) else add(ni) - } - } - } - - private fun handleMessageStatus(intent: Intent) { - val id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0) - val status = intent.getParcelableCompat("com.geeksville.mesh.Status", MessageStatus::class.java) - Log.d(TAG, "Message Status for ID $id: $status") - addToLog("Msg Status ID $id: $status") - } - - private fun handleReceivedPacket(action: String, intent: Intent) { - val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java) - if (packet == null) { - Log.e(TAG, "Received packet extra was NULL for action: $action") - addToLog("Error: Packet payload was null for $action") - return - } - - Log.d(TAG, "Packet received: $packet") - - if (packet.dataType == PortNum.TEXT_MESSAGE_APP.value) { - val receivedText = packet.bytes?.utf8() ?: "" - _message.value = "From ${packet.from}: $receivedText" - addToLog("Received Text from ${packet.from}: $receivedText") - } else { - val type = action.substringAfterLast(".") - addToLog("Received $type from ${packet.from}. Check Logcat for details.") - } - } - - private fun addToLog(entry: String) { - val date = nowMillis.toInstant().toDate() - val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(date) - val logEntry = "[$timestamp] $entry" - Log.d(TAG, "Log: $logEntry") - @Suppress("MagicNumber") - _packetLog.value = (listOf(logEntry) + _packetLog.value).take(50) - } - - private fun Intent.getParcelableCompat(key: String, clazz: Class): T? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelableExtra(key, clazz) - } else { - @Suppress("DEPRECATION") - getParcelableExtra(key) - } -} diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml deleted file mode 100644 index 07d5da9cb..000000000 --- a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d114..000000000 --- a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index 6f3b755bf..000000000 --- a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml b/mesh_service_example/src/main/res/values-ar-rSA/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml b/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-be-rBY/strings.xml b/mesh_service_example/src/main/res/values-be-rBY/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-be-rBY/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml b/mesh_service_example/src/main/res/values-bg-rBG/strings.xml deleted file mode 100644 index bebf8fbdd..000000000 --- a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Изпратете съобщение за здравей - diff --git a/mesh_service_example/src/main/res/values-ca-rES/strings.xml b/mesh_service_example/src/main/res/values-ca-rES/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ca-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml b/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-de-rDE/strings.xml b/mesh_service_example/src/main/res/values-de-rDE/strings.xml deleted file mode 100644 index 968230ec2..000000000 --- a/mesh_service_example/src/main/res/values-de-rDE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Beispiel MeshService - Hallo Nachricht senden - diff --git a/mesh_service_example/src/main/res/values-el-rGR/strings.xml b/mesh_service_example/src/main/res/values-el-rGR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-el-rGR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-es-rES/strings.xml b/mesh_service_example/src/main/res/values-es-rES/strings.xml deleted file mode 100644 index 8abd298f5..000000000 --- a/mesh_service_example/src/main/res/values-es-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Ejemplo de servicio de red - Enviar Mensaje Hola - diff --git a/mesh_service_example/src/main/res/values-et-rEE/strings.xml b/mesh_service_example/src/main/res/values-et-rEE/strings.xml deleted file mode 100644 index dd6ff8304..000000000 --- a/mesh_service_example/src/main/res/values-et-rEE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceNäidis - Saada Tere sõnum - diff --git a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml b/mesh_service_example/src/main/res/values-fi-rFI/strings.xml deleted file mode 100644 index 2da506dda..000000000 --- a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExamplebled - Lähetä tervehdysviesti - diff --git a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml b/mesh_service_example/src/main/res/values-fr-rFR/strings.xml deleted file mode 100644 index 2b9ff6e40..000000000 --- a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exemple de service de maillage - Envoyer un message d’annonce - diff --git a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml b/mesh_service_example/src/main/res/values-ga-rIE/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-gl-rES/strings.xml b/mesh_service_example/src/main/res/values-gl-rES/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-gl-rES/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml b/mesh_service_example/src/main/res/values-hr-rHR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml b/mesh_service_example/src/main/res/values-ht-rHT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml b/mesh_service_example/src/main/res/values-hu-rHU/strings.xml deleted file mode 100644 index 1cff8d920..000000000 --- a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Hello Üzenet Küldés - diff --git a/mesh_service_example/src/main/res/values-is-rIS/strings.xml b/mesh_service_example/src/main/res/values-is-rIS/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-is-rIS/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-it-rIT/strings.xml b/mesh_service_example/src/main/res/values-it-rIT/strings.xml deleted file mode 100644 index dd7addd1d..000000000 --- a/mesh_service_example/src/main/res/values-it-rIT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Invia Messaggio di Saluto - diff --git a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml b/mesh_service_example/src/main/res/values-iw-rIL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml b/mesh_service_example/src/main/res/values-ja-rJP/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml b/mesh_service_example/src/main/res/values-ko-rKR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml b/mesh_service_example/src/main/res/values-lt-rLT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml b/mesh_service_example/src/main/res/values-nl-rNL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-no-rNO/strings.xml b/mesh_service_example/src/main/res/values-no-rNO/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-no-rNO/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml b/mesh_service_example/src/main/res/values-pl-rPL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml b/mesh_service_example/src/main/res/values-pt-rBR/strings.xml deleted file mode 100644 index 4e232be75..000000000 --- a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - ExemploServiçoMesh - Enviar Mensagem de Olá - diff --git a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml b/mesh_service_example/src/main/res/values-pt-rPT/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml b/mesh_service_example/src/main/res/values-ro-rRO/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml b/mesh_service_example/src/main/res/values-ru-rRU/strings.xml deleted file mode 100644 index ba088c7e3..000000000 --- a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Отправить приветственное сообщение - diff --git a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml b/mesh_service_example/src/main/res/values-sk-rSK/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml b/mesh_service_example/src/main/res/values-sl-rSI/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml b/mesh_service_example/src/main/res/values-sq-rAL/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-srp/strings.xml b/mesh_service_example/src/main/res/values-srp/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-srp/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml b/mesh_service_example/src/main/res/values-sv-rSE/strings.xml deleted file mode 100644 index f9271ce44..000000000 --- a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Mesh-service exempel - Skicka Hej-meddelande - diff --git a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml b/mesh_service_example/src/main/res/values-tr-rTR/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml b/mesh_service_example/src/main/res/values-uk-rUA/strings.xml deleted file mode 100644 index 37d7a2bb2..000000000 --- a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Надіслати привітальне повідомлення - diff --git a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml b/mesh_service_example/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 30fbd6de5..000000000 --- a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - MeshServiceExample - Send Hello Message - diff --git a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml b/mesh_service_example/src/main/res/values-zh-rTW/strings.xml deleted file mode 100644 index 16c04c5d3..000000000 --- a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - Mesh 服務範例 - 發送打招呼訊息 - diff --git a/mesh_service_example/src/main/res/values/colors.xml b/mesh_service_example/src/main/res/values/colors.xml deleted file mode 100644 index a6b3daec9..000000000 --- a/mesh_service_example/src/main/res/values/colors.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/mesh_service_example/src/main/res/values/themes.xml b/mesh_service_example/src/main/res/values/themes.xml deleted file mode 100644 index e8f8fe799..000000000 --- a/mesh_service_example/src/main/res/values/themes.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - -