From 17e69c6d4c717cf75fa0fd61a9cba79e3465ac52 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:02:59 -0500 Subject: [PATCH] chore: review-cleanup fleet (audit + fix + hardening) (#5158) --- .github/lsp.json | 12 ++ .github/workflows/models_pr_triage.yml | 14 +- .skills/new-branch/SKILL.md | 79 +++++++ AGENTS.md | 28 +++ CLAUDE.md | 2 +- app/proguard-rules.pro | 37 +--- .../kotlin/org/meshtastic/app/map/MapView.kt | 12 +- build-logic/convention/build.gradle.kts | 1 - .../AndroidApplicationConventionPlugin.kt | 16 +- .../KmpLibraryComposeConventionPlugin.kt | 10 +- .../main/kotlin/KmpLibraryConventionPlugin.kt | 4 - .../meshtastic/buildlogic/KotlinAndroid.kt | 4 +- config/proguard/shared-rules.pro | 179 ++++++++++++++++ .../meshtastic/core/common/util/Exceptions.kt | 15 ++ .../data/manager/MeshActionHandlerImpl.kt | 4 +- .../core/data/manager/PacketHandlerImpl.kt | 31 ++- .../data/repository/PacketRepositoryImpl.kt | 6 +- .../data/manager/MeshActionHandlerImplTest.kt | 9 +- .../core/database/dao/CommonPacketDaoTest.kt | 36 ++++ core/model/build.gradle.kts | 2 + core/navigation/build.gradle.kts | 2 + .../repository/TrustAllX509TrustManager.kt | 31 --- .../core/network/radio/BleRadioTransport.kt | 16 +- .../core/network/radio/MockRadioTransport.kt | 2 +- .../core/network/radio/NopRadioTransport.kt | 2 +- .../core/network/radio/StreamTransport.kt | 2 +- .../core/network/radio/TcpRadioTransport.kt | 2 +- .../core/network/SerialTransport.kt | 2 +- .../org/meshtastic/core/prefs/FlowCache.kt | 38 ++-- .../core/prefs/map/MapConsentPrefsImpl.kt | 2 +- .../core/prefs/mesh/MeshPrefsImpl.kt | 15 +- .../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 2 +- .../core/repository/AppPreferences.kt | 4 - .../core/repository/RadioInterfaceService.kt | 19 +- .../core/repository/RadioTransport.kt | 13 +- .../core/repository/RadioTransportTest.kt | 5 +- .../composeResources/values/strings.xml | 4 + .../core/service/MeshServiceOrchestrator.kt | 65 +++--- .../service/SharedRadioInterfaceService.kt | 36 +++- .../service/MeshServiceOrchestratorTest.kt | 86 +++++++- .../core/takserver/TAKServerManager.kt | 24 ++- core/testing/build.gradle.kts | 4 +- .../core/testing/FakeAppPreferences.kt | 9 - .../core/testing/FakeRadioInterfaceService.kt | 20 +- .../core/testing/FakeRadioTransport.kt | 2 +- desktop/build.gradle.kts | 5 +- desktop/proguard-rules.pro | 194 +++--------------- .../kotlin/org/meshtastic/desktop/Main.kt | 11 +- .../DesktopMeshServiceNotifications.kt | 3 +- .../org/meshtastic/desktop/stub/NoopStubs.kt | 4 + .../CommonGetDiscoveredDevicesUseCase.kt | 7 +- .../ui/components/DeviceListItem.kt | 9 +- .../feature/firmware/ota/BleOtaTransport.kt | 3 +- .../firmware/ota/Esp32OtaUpdateHandler.kt | 6 +- .../feature/firmware/ota/WifiOtaTransport.kt | 2 +- .../firmware/ota/dfu/SecureDfuHandler.kt | 4 +- .../firmware/ota/dfu/SecureDfuTransport.kt | 7 +- .../DefaultFirmwareUpdateManagerTest.kt | 10 + feature/settings/build.gradle.kts | 1 + .../feature/settings/tak/TakPermissionUtil.kt | 2 +- .../navigation/AboutLibrariesLoader.kt | 0 .../navigation/AboutLibrariesLoader.kt | 22 -- .../feature/widget/LocalStatsWidgetState.kt | 17 +- feature/wifi-provision/build.gradle.kts | 1 + .../wifiprovision/WifiProvisionViewModel.kt | 4 +- .../wifiprovision/domain/NymeaWifiService.kt | 3 +- .../WifiProvisionViewModelTest.kt | 11 +- gradle/libs.versions.toml | 9 - 68 files changed, 784 insertions(+), 459 deletions(-) create mode 100644 .github/lsp.json create mode 100644 .skills/new-branch/SKILL.md create mode 100644 config/proguard/shared-rules.pro delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt rename feature/settings/src/{androidMain => jvmAndroidMain}/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt (100%) delete mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt diff --git a/.github/lsp.json b/.github/lsp.json new file mode 100644 index 000000000..983ecf785 --- /dev/null +++ b/.github/lsp.json @@ -0,0 +1,12 @@ +{ + "lspServers": { + "kotlin": { + "command": "kotlin-language-server", + "args": [], + "fileExtensions": { + ".kt": "kotlin", + ".kts": "kotlin" + } + } + } +} diff --git a/.github/workflows/models_pr_triage.yml b/.github/workflows/models_pr_triage.yml index 2cfe6b15e..c2a1aaf25 100644 --- a/.github/workflows/models_pr_triage.yml +++ b/.github/workflows/models_pr_triage.yml @@ -44,13 +44,16 @@ jobs: uses: actions/ai-inference@v2 id: quality continue-on-error: true + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} with: max-tokens: 20 prompt: | Is this GitHub pull request spam, AI-generated slop, or low quality? - Title: ${{ github.event.pull_request.title }} - Body: ${{ github.event.pull_request.body }} + Title: ${{ env.PR_TITLE }} + Body: ${{ env.PR_BODY }} Respond with exactly one of: spam, ai-generated, needs-review, ok system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop. @@ -94,6 +97,9 @@ jobs: uses: actions/ai-inference@v2 id: classify continue-on-error: true + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} with: max-tokens: 30 prompt: | @@ -105,8 +111,8 @@ jobs: Use enhancement if it adds a new feature, improves performance, or adds new functionality. Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture. - Title: ${{ github.event.pull_request.title }} - Body: ${{ github.event.pull_request.body }} + Title: ${{ env.PR_TITLE }} + Body: ${{ env.PR_BODY }} system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label. model: openai/gpt-4o-mini diff --git a/.skills/new-branch/SKILL.md b/.skills/new-branch/SKILL.md new file mode 100644 index 000000000..d63f3f4c2 --- /dev/null +++ b/.skills/new-branch/SKILL.md @@ -0,0 +1,79 @@ +# Skill: New Branch Bootstrap + +## Description +Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill +whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh +branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work. + +This replaces the ad-hoc prose that used to be retyped at the start of every session. + +## When to Use +- Starting any new feature, fix, chore, or refactor. +- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)). +- Reproducing a CI failure from a clean baseline. + +## Preconditions (verify before branching) +1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding. +2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at + `meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream. +3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md + workspace bootstrap rules. +4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties` + (required for `google` flavor builds). + +## Standard Recipe + +```bash +# 1. Fetch latest upstream +git fetch upstream --prune --tags + +# 2. Create the branch from upstream/main (never from a local stale main) +git switch -c upstream/main + +# 3. Ensure submodules track the new base +git submodule update --init --recursive + +# 4. Sanity check +git --no-pager log -1 --oneline +``` + +## Branch Naming +Use conventional-commit style prefixes that match the PR title convention in AGENTS.md +``: + +| Prefix | Use for | +| :--- | :--- | +| `feat/` | New user-visible behavior | +| `fix/` | Bug fixes | +| `refactor/` | Code structure changes, no behavior change | +| `chore/` | Tooling, deps, CI, cleanup | +| `docs/` | Documentation only | + +Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`. + +## Rebase Variant +When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*: + +```bash +git fetch upstream --prune +gh pr checkout # checks out the PR head locally +git rebase upstream/main +git submodule update --init --recursive +# Resolve conflicts, then: +git push --force-with-lease +``` + +Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes. + +## Post-Branch Checklist +- [ ] Branch name follows conventional prefix. +- [ ] Submodules up to date. +- [ ] `local.properties` exists. +- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap). +- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing. + +## Tip: Prefer `/delegate` for Long Audits +If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since +v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider +suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR +end-to-end while the user keeps working locally. See AGENTS.md ``. diff --git a/AGENTS.md b/AGENTS.md index 07d9b0050..c1bafdd96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes - `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties. - `.skills/implement-feature/` - Step-by-step feature workflow. - `.skills/code-review/` - PR validation checklist. + - `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs. - **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch. @@ -73,6 +74,33 @@ Do NOT duplicate content into agent-specific files. When you modify architecture - **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change. + +These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this +section. + +- **Delegate long autonomous work.** For sweeping audits, multi-hour investigations, or "fleet" + prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*, + *"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub + cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive + session on work that can run unattended. +- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"* + on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep + research across GitHub and the web with better source grounding than an ad-hoc prompt. +- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation + plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to + plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent + from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to + `.agent_plans/` (git-ignored) for multi-module refactors. +- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle + quality passes, offer `/share` to export the findings to a gist or markdown file. These + reports are valuable artifacts — don't let them die in session history. +- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts + file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration. + Avoid re-issuing the same prompt verbatim. +- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main" + or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe. + + - **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation. - **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style. diff --git a/CLAUDE.md b/CLAUDE.md index 39958ecd0..eb5cd5e5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,4 +6,4 @@ - **Think First:** Always outline your step-by-step reasoning inside `` tags before writing code or shell commands. Claude models perform significantly better on complex KMP tasks when they "think out loud" first. - **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task. -- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). +- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `` section. diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d443e173a..14df5580d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -3,13 +3,17 @@ # ============================================================================ # Open-source project: obfuscation and optimization are disabled. We rely on # tree-shaking (unused code removal) for APK size reduction. +# +# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, +# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, +# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in +# config/proguard/shared-rules.pro and are wired in by the +# AndroidApplicationConventionPlugin. This file holds only Android-specific +# rules and R8-only directives. # ============================================================================ # ---- General ---------------------------------------------------------------- -# Preserve line numbers for meaningful crash traces --keepattributes SourceFile,LineNumberTable - # Open-source — no need to obfuscate -dontobfuscate @@ -30,28 +34,12 @@ # for auditing. Inspect this file after a release build to see what libraries inject. -printconfiguration build/outputs/mapping/r8-merged-config.txt -# ---- Networking (transitive references from Ktor) --------------------------- +# ---- Networking (transitive references from Ktor on Android) ---------------- -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** -# ---- Wire Protobuf ---------------------------------------------------------- - -# Wire-generated proto message classes (accessed via ADAPTER companion reflection) --keep class org.meshtastic.proto.** { *; } - -# ---- Room KMP (room3) ------------------------------------------------------ - -# Preserve generated database constructors (Room uses reflection to instantiate) --keep class * extends androidx.room3.RoomDatabase { (); } - -# ---- Koin DI ---------------------------------------------------------------- - -# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException -# replacing Koin's InstanceCreationException in stack traces, making crashes undiagnosable). --keep class org.koin.core.error.** { *; } - # ---- Compose Runtime & Animation -------------------------------------------- # Defence-in-depth: prevent R8 tree-shaking of Compose infrastructure classes @@ -64,12 +52,3 @@ -keep class androidx.compose.animation.** { *; } -keep class androidx.compose.foundation.** { *; } -keep class androidx.compose.material3.** { *; } - -# ---- Compose Multiplatform -------------------------------------------------- - -# Keep resource library internals and generated Res accessor classes so R8 does -# not tree-shake the resource loading infrastructure. Without these rules the -# fdroid flavor crashes at startup with a misleading URLDecodeException due to -# R8 exception-class merging. --keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.** { *; } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 657f7ab74..b4d0e1bbd 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -861,15 +861,9 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { onDismiss = onDismiss, negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, ) { - Text( - modifier = Modifier.padding(16.dp), - text = - stringResource( - Res.string.map_cache_info, - cacheCapacity / (1024.0 * 1024.0), - currentCacheUsage / (1024.0 * 1024.0), - ), - ) + val capacityMb = (cacheCapacity / (1024 * 1024)).toLong() + val usageMb = (currentCacheUsage / (1024 * 1024)).toLong() + Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb)) } } diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index faaeb9f68..71823c763 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -54,7 +54,6 @@ dependencies { compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.androidx.room.gradlePlugin) - compileOnly(libs.secrets.gradlePlugin) compileOnly(libs.spotless.gradlePlugin) compileOnly(libs.test.retry.gradlePlugin) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index cc53f27ec..38cc021a7 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - import com.android.build.api.dsl.ApplicationExtension import org.gradle.api.Plugin import org.gradle.api.Project @@ -26,7 +25,6 @@ import org.meshtastic.buildlogic.configureTestOptions class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - apply(plugin = "com.android.application") apply(plugin = "org.gradle.test-retry") apply(plugin = "meshtastic.android.lint") @@ -38,11 +36,8 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - - defaultConfig { - vectorDrawables.useSupportLibrary = true - } + defaultConfig { vectorDrawables.useSupportLibrary = true } buildTypes { getByName("release") { @@ -50,7 +45,8 @@ class AndroidApplicationConventionPlugin : Plugin { isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + rootProject.file("config/proguard/shared-rules.pro"), + "proguard-rules.pro", ) } getByName("debug") { @@ -62,9 +58,7 @@ class AndroidApplicationConventionPlugin : Plugin { } } - buildFeatures { - buildConfig = true - } + buildFeatures { buildConfig = true } } configureTestOptions() } diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt index 2a9504221..67b2c8fd0 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt @@ -32,10 +32,12 @@ class KmpLibraryComposeConventionPlugin : Plugin { apply(plugin = libs.plugin("compose-multiplatform").get().pluginId) extensions.configure { - sourceSets.getByName("commonMain").dependencies { - implementation(libs.library("compose-multiplatform-runtime")) - // API because consuming modules will usually need the resource types - api(libs.library("compose-multiplatform-resources")) + sourceSets.matching { it.name == "commonMain" }.configureEach { + dependencies { + implementation(libs.library("compose-multiplatform-runtime")) + // API because consuming modules will usually need the resource types + api(libs.library("compose-multiplatform-resources")) + } } } configureComposeCompiler() diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt index a1a111a64..540834ef5 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -14,11 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -import dev.mokkery.gradle.MokkeryGradleExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply -import org.gradle.kotlin.dsl.configure import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback import org.meshtastic.buildlogic.configureKmpTestDependencies import org.meshtastic.buildlogic.configureKotlinMultiplatform @@ -39,8 +37,6 @@ class KmpLibraryConventionPlugin : Plugin { apply(plugin = "org.gradle.test-retry") apply(plugin = libs.plugin("mokkery").get().pluginId) - extensions.configure { stubs.allowConcreteClassInstantiation.set(true) } - configureKotlinMultiplatform() configureKmpTestDependencies() configureTestOptions() diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index c7afeaf39..088ca0d25 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -77,7 +77,9 @@ internal fun Project.configureKotlinMultiplatform() { // upgrades to the CMP-bundled version, triggering a "Skiko dependencies' // versions are incompatible" warning from CMP's compatibility checker. // Force the version to match CMP so the checker sees a consistent graph. - val skikoVersion = libs.version("skiko") + // Pinned here rather than in the version catalog because this plugin is the + // only consumer — bump together with the compose-multiplatform version. + val skikoVersion = "0.144.5" configurations.configureEach { resolutionStrategy.eachDependency { if (requested.group == "org.jetbrains.skiko") { diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro new file mode 100644 index 000000000..902636dbf --- /dev/null +++ b/config/proguard/shared-rules.pro @@ -0,0 +1,179 @@ +# ============================================================================ +# Meshtastic — Shared ProGuard / R8 rules +# ============================================================================ +# Cross-platform keep and dontwarn rules applied to BOTH the Android app +# release build (R8) and the Desktop distribution (ProGuard). Host-specific +# rules live in the per-module proguard-rules.pro file. +# +# Rule of thumb: anything describing a library shared between Android and +# Desktop (Koin, kotlinx-serialization, Wire, Room KMP, Ktor, Coil 3, Kable, +# Kermit, Okio, DataStore, Paging, Lifecycle / Navigation 3, AboutLibraries, +# Markdown renderer, QRCode, Compose Multiplatform resources, core modules) +# belongs here. Anything platform-specific (AWT/Skiko/JNA, AIDL, Android +# framework, JDK-version quirks, flavor specifics) stays in the host file. +# ============================================================================ + +# ---- Attributes ------------------------------------------------------------- + +# Preserve line numbers for meaningful stack traces, plus metadata needed for +# reflective serializer/DI/Room lookups. +-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations + +# ---- Kotlin / Coroutines ---------------------------------------------------- + +-keep class kotlin.Metadata { *; } +-keep class kotlin.reflect.** { *; } +-keep class kotlin.coroutines.Continuation { *; } +-keep class kotlinx.coroutines.** { *; } +-dontwarn kotlinx.coroutines.** + +# ---- Koin DI (reflection-based injection) ----------------------------------- + +# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException +# replacing Koin's InstanceCreationException in stack traces, making crashes +# undiagnosable). Broadened to all of koin core to cover the KSP-generated graph. +-keep class org.koin.** { *; } +-dontwarn org.koin.** + +# Keep Koin-annotated modules/components so Koin Annotations (KSP) output +# survives tree-shaking. +-keep @org.koin.core.annotation.Module class * { *; } +-keep @org.koin.core.annotation.ComponentScan class * { *; } +-keep @org.koin.core.annotation.Single class * { *; } +-keep @org.koin.core.annotation.Factory class * { *; } + +# Generated Koin module extensions (Koin Annotations plugin output) +-keep class org.meshtastic.**.di.** { *; } + +# ---- kotlinx-serialization -------------------------------------------------- + +-keep class kotlinx.serialization.** { *; } +-dontwarn kotlinx.serialization.** + +# Keep @Serializable classes and their generated $serializer companions +-keepclassmembers @kotlinx.serialization.Serializable class ** { + static ** Companion; + kotlinx.serialization.KSerializer serializer(...); +} +-keep class **.$serializer { *; } +-keepclassmembers class **.$serializer { *; } +-keepclasseswithmembers class ** { + kotlinx.serialization.KSerializer serializer(...); +} + +# ---- Wire Protobuf ---------------------------------------------------------- + +# Wire generates ADAPTER companion objects accessed via reflection +-keep class com.squareup.wire.** { *; } +-dontwarn com.squareup.wire.** + +# Generated proto message classes (both meshtastic protos and internal package) +-keep class org.meshtastic.proto.** { *; } +-keep class meshtastic.** { *; } + +# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs +# when compiling for non-Android JVM targets; harmless on Android). +-dontwarn android.os.Parcel** +-dontwarn android.os.Parcelable** + +# ---- Room KMP (room3) ------------------------------------------------------- + +# Preserve generated database constructors (Room uses reflection to instantiate) +-keep class * extends androidx.room3.RoomDatabase { (); } +-keep class * implements androidx.room3.RoomDatabaseConstructor { *; } + +# Keep the expect/actual MeshtasticDatabaseConstructor + database surface +-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } +-keep class org.meshtastic.core.database.MeshtasticDatabase { *; } + +# Room DAOs — Room generates implementations at compile time; keep interfaces +-keep class org.meshtastic.core.database.dao.** { *; } + +# Room Entities — accessed via reflection for column mapping +-keep class org.meshtastic.core.database.entity.** { *; } + +# Room TypeConverters — invoked reflectively +-keep class org.meshtastic.core.database.Converters { *; } + +# Room generated _Impl classes +-keep class **_Impl { *; } + +# ---- SQLite bundled -------------------------------------------------------- + +-keep class androidx.sqlite.** { *; } +-dontwarn androidx.sqlite.** + +# ---- Ktor (ServiceLoader + plugin discovery) -------------------------------- + +-keep class io.ktor.** { *; } +-dontwarn io.ktor.** + +# Keep ServiceLoader metadata files +-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } + +# ---- Coil 3 (image loading) ------------------------------------------------- + +-keep class coil3.** { *; } +-dontwarn coil3.** + +# ---- Kable BLE -------------------------------------------------------------- + +-keep class com.juul.kable.** { *; } +-dontwarn com.juul.kable.** + +# ---- Compose Multiplatform resources ---------------------------------------- + +# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.). +# Without these the fdroid flavor has crashed at startup with a misleading +# URLDecodeException due to R8 exception-class merging. +-keep class org.jetbrains.compose.resources.** { *; } +-keep class org.meshtastic.core.resources.** { *; } + +# ---- AboutLibraries --------------------------------------------------------- + +-keep class com.mikepenz.aboutlibraries.** { *; } +-dontwarn com.mikepenz.aboutlibraries.** + +# ---- Multiplatform Markdown Renderer ---------------------------------------- + +-keep class com.mikepenz.markdown.** { *; } +-dontwarn com.mikepenz.markdown.** + +# ---- QR Code Kotlin --------------------------------------------------------- + +-keep class io.github.g0dkar.qrcode.** { *; } +-dontwarn io.github.g0dkar.qrcode.** +-keep class qrcode.** { *; } +-dontwarn qrcode.** + +# ---- Kermit logging --------------------------------------------------------- + +-keep class co.touchlab.kermit.** { *; } +-dontwarn co.touchlab.kermit.** + +# ---- Okio ------------------------------------------------------------------- + +-keep class okio.** { *; } +-dontwarn okio.** + +# ---- DataStore -------------------------------------------------------------- + +-keep class androidx.datastore.** { *; } +-dontwarn androidx.datastore.** + +# ---- Paging ----------------------------------------------------------------- + +-keep class androidx.paging.** { *; } +-dontwarn androidx.paging.** + +# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) ----------------- + +-keep class androidx.lifecycle.** { *; } +-keep class androidx.navigation3.** { *; } +-dontwarn androidx.lifecycle.** +-dontwarn androidx.navigation3.** + +# ---- Meshtastic shared model ------------------------------------------------ + +# Core model classes (used in serialization, Room, and Koin injection) +-keep class org.meshtastic.core.model.** { *; } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt index c5d3c2091..92137375c 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt @@ -95,3 +95,18 @@ inline fun T.safeCatching(block: T.() -> R): Result = try { } catch (e: Exception) { Result.failure(e) } + +/** + * Like [safeCatching] but also catches JVM [Error]s (e.g. [ExceptionInInitializerError] raised by compose-resources' + * lazy skiko initialization on the desktop JVM test classpath). Still re-throws [CancellationException] so structured + * concurrency is preserved. Use when the block invokes code whose failure modes include static-initializer errors and + * the caller only needs a best-effort fallback. + */ +@Suppress("TooGenericExceptionCaught") +inline fun safeCatchingAll(block: () -> T): Result = try { + Result.success(block()) +} catch (e: CancellationException) { + throw e +} catch (t: Throwable) { + Result.failure(t) +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index 975b2f5e8..ab4f3a551 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -45,6 +45,7 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -63,6 +64,7 @@ class MeshActionHandlerImpl( private val dataHandler: Lazy, private val analytics: PlatformAnalytics, private val meshPrefs: MeshPrefs, + private val uiPrefs: UiPrefs, private val databaseManager: DatabaseManager, private val notificationManager: NotificationManager, private val messageProcessor: Lazy, @@ -207,7 +209,7 @@ class MeshActionHandlerImpl( override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) { if (destNum != myNodeNum) { - val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value + val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value val currentPosition = when { provideLocation && position.isValid() -> position diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt index 7c634ee8b..e2e9a8432 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -22,7 +22,9 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -73,6 +75,11 @@ class PacketHandlerImpl( private val queueMutex = Mutex() private val queuedPackets = mutableListOf() + // Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket) + // calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and + // a single consumer coroutine enqueues packets under queueMutex in arrival order. + private val outboundChannel = Channel(Channel.UNLIMITED) + // Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked() // and the queue processor's finally block to prevent restarting a stopped queue. private var queueStopped = false @@ -80,6 +87,20 @@ class PacketHandlerImpl( private val responseMutex = Mutex() private val queueResponse = mutableMapOf>() + init { + // Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket) + // entry point, preserving FIFO across rapid concurrent callers. + scope.launch { + outboundChannel.consumeAsFlow().collect { packet -> + queueMutex.withLock { + queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. + queuedPackets.add(packet) + startPacketQueueLocked() + } + } + } + } + override fun sendToRadio(p: ToRadio) { Logger.d { "Sending to radio ${p.toPIIString()}" } val b = p.encode() @@ -104,13 +125,9 @@ class PacketHandlerImpl( } override fun sendToRadio(packet: MeshPacket) { - scope.launch { - queueMutex.withLock { - queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. - queuedPackets.add(packet) - startPacketQueueLocked() - } - } + // Non-suspend entry point — order-preserving via unbounded channel drained by + // a single consumer coroutine. trySend on UNLIMITED never fails for capacity. + outboundChannel.trySend(packet) } @Suppress("TooGenericExceptionCaught", "SwallowedException") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index a5664b1a0..149c62d2b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.dao.NodeInfoDao import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.database.entity.toReaction import org.meshtastic.core.di.CoroutineDispatchers @@ -242,7 +243,10 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val emptyMap() } else { withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().getPacketsByPacketIds(ids).associateBy { it.packet.packetId } + val dao = dbManager.currentDb.value.packetDao() + ids.chunked(NodeInfoDao.MAX_BIND_PARAMS) + .flatMap { dao.getPacketsByPacketIds(it) } + .associateBy { it.packet.packetId } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt index c53c2577e..5b29e9f26 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt @@ -47,6 +47,7 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -68,6 +69,7 @@ class MeshActionHandlerImplTest { private val dataHandler = mock(MockMode.autofill) private val analytics = mock(MockMode.autofill) private val meshPrefs = mock(MockMode.autofill) + private val uiPrefs = mock(MockMode.autofill) private val databaseManager = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) private val messageProcessor = mock(MockMode.autofill) @@ -100,6 +102,7 @@ class MeshActionHandlerImplTest { dataHandler = lazy { dataHandler }, analytics = analytics, meshPrefs = meshPrefs, + uiPrefs = uiPrefs, databaseManager = databaseManager, notificationManager = notificationManager, messageProcessor = lazy { messageProcessor }, @@ -356,7 +359,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() { handler = createHandler(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) val validPosition = Position(37.7749, -122.4194, 10) handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) @@ -367,7 +370,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() { handler = createHandler(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) every { nodeManager.nodeDBbyNodeNum } returns emptyMap() val invalidPosition = Position(0.0, 0.0, 0) @@ -380,7 +383,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_doNotProvide_sendsZeroPosition() { handler = createHandler(testScope) - every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) + every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) val validPosition = Position(37.7749, -122.4194, 10) handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt index 6da9df5b7..71a7fef1c 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt @@ -271,6 +271,42 @@ abstract class CommonPacketDaoTest { assertFalse(excludingFiltered.any { it.packet.filtered }) } + @Test + fun testGetPacketsByPacketIdsChunked() = runTest { + // Regression test for SQLITE_MAX_VARIABLE_NUMBER (999) limit. Inserting >999 packets and + // looking them up by id must not throw; callers are expected to chunk, and each chunk + // must return the correct rows. + val totalPackets = 2000 + val chunkSize = NodeInfoDao.MAX_BIND_PARAMS + val contactKey = "chunk-test" + val baseTime = nowMillis + val packetIds = (1..totalPackets).toList() + + packetIds.forEach { id -> + packetDao.insert( + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = PortNum.TEXT_MESSAGE_APP.value, + contact_key = contactKey, + received_time = baseTime + id, + read = false, + data = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = "Chunk $id".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ), + packetId = id, + ), + ) + } + + val fetched = packetIds.chunked(chunkSize).flatMap { packetDao.getPacketsByPacketIds(it) } + assertEquals(totalPackets, fetched.size) + assertEquals(packetIds.toSet(), fetched.map { it.packet.packetId }.toSet()) + } + companion object { private const val SAMPLE_SIZE = 10 } diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 4e01fc223..92374706a 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -58,6 +58,8 @@ kotlin { implementation(libs.androidx.test.runner) } } + + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 99a0802ae..858229b69 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -32,5 +32,7 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) implementation(libs.kermit) } + + commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt deleted file mode 100644 index 720d2a522..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.repository - -import android.annotation.SuppressLint -import java.security.cert.X509Certificate -import javax.net.ssl.X509TrustManager - -@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") -@Suppress("EmptyFunctionBlock") -class TrustAllX509TrustManager : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - - override fun checkServerTrusted(chain: Array?, authType: String?) {} - - override fun getAcceptedIssuers(): Array = arrayOf() -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt index cfc84c668..77114ff55 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt @@ -22,9 +22,8 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope @@ -37,6 +36,7 @@ import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.meshtastic.core.ble.BleConnection import org.meshtastic.core.ble.BleConnectionFactory @@ -396,14 +396,14 @@ class BleRadioTransport( } /** Closes the connection to the device. */ - override fun close() { + override suspend fun close() { Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" } connectionScope.cancel("close() called") - // GATT cleanup must outlive scope cancellation — GlobalScope is intentional. - // SharedRadioInterfaceService cancels the scope immediately after close(), so a - // coroutine launched there may never run, leaking BluetoothGatt (causes GATT 133). - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch { + // GATT cleanup must run under NonCancellable so a cancelled caller cannot skip it, + // which would leak BluetoothGatt and trigger status 133 on the next reconnect. + // Using withContext (not runBlocking) keeps the caller's thread free — this is + // critical when close() is invoked from the main thread during process shutdown. + withContext(NonCancellable) { try { withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() } } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt index b14c1bfe4..f8edeaa73 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt @@ -144,7 +144,7 @@ class MockRadioTransport( } } - override fun close() { + override suspend fun close() { Logger.i { "Closing the mock transport" } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt index db807081a..c8143b1c7 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt @@ -30,7 +30,7 @@ class NopRadioTransport(val address: String) : RadioTransport { // No-op } - override fun close() { + override suspend fun close() { // No-op } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt index ff2e5e33e..ac912346a 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt @@ -35,7 +35,7 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p private val codec = StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport") - override fun close() { + override suspend fun close() { Logger.d { "Closing stream for good" } onDeviceDisconnect(true) } diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt index 7b1106dc4..354c4cd30 100644 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt @@ -74,7 +74,7 @@ open class TcpRadioTransport( transport.start(address) } - override fun close() { + override suspend fun close() { Logger.d { "[$address] Closing TCP transport" } closing = true transport.stop() diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt index d43063d52..a3f34d67e 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt @@ -154,7 +154,7 @@ private constructor( serialPort = null } - override fun close() { + override suspend fun close() { Logger.d { "[$portName] Closing serial transport" } readJob?.cancel() readJob = null diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt index 5395ce723..d6c85d266 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt @@ -19,19 +19,31 @@ package org.meshtastic.core.prefs import kotlinx.atomicfu.AtomicRef import kotlinx.collections.immutable.PersistentMap -internal inline fun cachedFlow(cache: AtomicRef>, key: K, build: () -> V): V { - var resolved = cache.value[key] - if (resolved == null) { - val newValue = build() - while (resolved == null) { - val current = cache.value - val currentValue = current[key] - if (currentValue != null) { - resolved = currentValue - } else if (cache.compareAndSet(current, current.put(key, newValue))) { - resolved = newValue - } +/** + * Look up [key] in [cache]; if absent, construct a value via [build] and insert it atomically. + * + * [build] is wrapped in a [Lazy] before being published to [cache], so concurrent first-access of the same key never + * invokes [build] more than once — only the winner of the CAS has its [Lazy] evaluated, and all readers share that same + * result. This matters when [build] eagerly launches a coroutine (e.g. `Flow.stateIn(scope, Eagerly, …)`): the naive + * approach would leak the losing coroutine into a never-cancelled scope. + */ +@Suppress("ReturnCount") +internal inline fun cachedFlow( + cache: AtomicRef>>, + key: K, + crossinline build: () -> V, +): V { + cache.value[key]?.let { + return it.value + } + val newLazy = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { build() } + while (true) { + val current = cache.value + current[key]?.let { + return it.value + } + if (cache.compareAndSet(current, current.put(key, newLazy))) { + return newLazy.value } } - return checkNotNull(resolved) } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt index 763c81120..c43d4b2bb 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt @@ -42,7 +42,7 @@ class MapConsentPrefsImpl( ) : MapConsentPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - private val consentFlows = atomic(persistentMapOf>()) + private val consentFlows = atomic(persistentMapOf>>()) override fun shouldReportLocation(nodeNum: Int?): StateFlow = cachedFlow(consentFlows, nodeNum) { val key = booleanPreferencesKey(nodeNum.toString()) diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt index 2292ea3ab..f3ddaad4e 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.prefs.mesh import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -45,8 +44,7 @@ class MeshPrefsImpl( ) : MeshPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - private val locationFlows = atomic(persistentMapOf>()) - private val storeForwardFlows = atomic(persistentMapOf>()) + private val storeForwardFlows = atomic(persistentMapOf>>()) override val deviceAddress: StateFlow = dataStore.data @@ -65,15 +63,6 @@ class MeshPrefsImpl( } } - override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = cachedFlow(locationFlows, nodeNum) { - val key = booleanPreferencesKey(provideLocationKey(nodeNum)) - dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) - } - - override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) { - scope.launch { dataStore.edit { prefs -> prefs[booleanPreferencesKey(provideLocationKey(nodeNum))] = provide } } - } - override fun getStoreForwardLastRequest(address: String?): StateFlow = cachedFlow(storeForwardFlows, address) { val key = intPreferencesKey(storeForwardKey(address)) dataStore.data.map { it[key] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0) @@ -92,8 +81,6 @@ class MeshPrefsImpl( } } - private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum" - private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}" companion object { diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index 7fe0da822..c0b88d385 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -46,7 +46,7 @@ class UiPrefsImpl( private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) // Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref - private val provideNodeLocationFlows = atomic(persistentMapOf>()) + private val provideNodeLocationFlows = atomic(persistentMapOf>>()) override val appIntroCompleted: StateFlow = dataStore.data.map { it[KEY_APP_INTRO_COMPLETED] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index bb32c1fbd..d7400332d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -213,10 +213,6 @@ interface MeshPrefs { fun setDeviceAddress(address: String?) - fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow - - fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) - fun getStoreForwardLastRequest(address: String?): StateFlow fun setStoreForwardLastRequest(address: String?, timestamp: Int) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index 8dcc21c71..cbaf8b3dc 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.repository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState @@ -68,12 +69,26 @@ interface RadioInterfaceService : RadioTransportCallback { /** Whether we are currently using a mock transport. */ fun isMockTransport(): Boolean - /** Flow of raw data received from the radio. */ - val receivedData: SharedFlow + /** + * Flow of raw data received from the radio. + * + * Emissions preserve the order in which bytes arrived from the hardware — this is required because the firmware + * handshake (initial config packet ordering) depends on strict FIFO delivery. Implementations MUST guarantee + * ordering; do not swap in a [SharedFlow] without preserving order. + */ + val receivedData: Flow /** Flow of radio activity events. */ val meshActivity: SharedFlow + /** + * Drains any bytes currently buffered in [receivedData] without emitting them to collectors. + * + * Callers invoke this before attaching a fresh collector after a stop/start cycle so stale bytes buffered while no + * collector was attached do not get replayed ahead of the next session's handshake. + */ + fun resetReceivedBuffer() + /** Sends a raw byte array to the radio. */ fun sendToRadio(bytes: ByteArray) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt index c6132a103..c0572f83f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt @@ -16,13 +16,11 @@ */ package org.meshtastic.core.repository -import okio.Closeable - /** * Interface for hardware transports (BLE, Serial, TCP, etc.) that handles raw byte communication. This is the * KMP-compatible replacement for the legacy Android-specific IRadioInterface. */ -interface RadioTransport : Closeable { +interface RadioTransport { /** Sends a raw byte array to the radio hardware. */ fun handleSendToRadio(p: ByteArray) @@ -39,4 +37,13 @@ interface RadioTransport : Closeable { * function can be implemented by transports to see if we are really connected. */ fun keepAlive() {} + + /** + * Closes the connection to the device. + * + * Implementations that perform potentially-blocking teardown (e.g. BLE GATT disconnect) MUST run that work inside + * `withContext(NonCancellable)` so a cancelled caller cannot skip cleanup, leaving the underlying resource leaked. + * Callers must invoke this from a coroutine — it must never be called from a blocking context (no `runBlocking`). + */ + suspend fun close() } diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt index dbc951d2a..303b8a4ad 100644 --- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt +++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt @@ -16,13 +16,14 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertTrue class RadioTransportTest { @Test - fun `RadioTransport can be implemented`() { + fun `RadioTransport can be implemented`() = runTest { var sentData: ByteArray? = null var closed = false var keepAliveCalled = false @@ -37,7 +38,7 @@ class RadioTransportTest { keepAliveCalled = true } - override fun close() { + override suspend fun close() { closed = true } } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index a958ce1ee..4748844c6 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1261,4 +1261,8 @@ Enter or select a network WiFi configured successfully! Failed to apply WiFi configuration + Meshtastic Desktop + Show Meshtastic + Quit + Meshtastic diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 50e88cc3f..ebac9f71b 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -19,13 +19,15 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.koin.core.annotation.Named +import kotlinx.coroutines.isActive import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshRouter @@ -59,18 +61,15 @@ class MeshServiceOrchestrator( private val takPrefs: TakPrefs, private val databaseManager: DatabaseManager, private val connectionManager: MeshConnectionManager, - @Named("ServiceScope") private val scope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, ) { - private var serviceJob: Job? = null - private var takJob: Job? = null - - /** The coroutine scope for the service. */ - val serviceScope: CoroutineScope - get() = scope + // Per-start coroutine scope. A fresh scope is created on each start() and cancelled on stop(), so all collectors + // launched from start() are torn down cleanly and do not accumulate across start/stop/start cycles. + private var scope: CoroutineScope? = null /** Whether the orchestrator is currently running. */ val isRunning: Boolean - get() = serviceJob?.isActive == true + get() = scope?.isActive == true /** * Starts the mesh service components and wires up data flows. @@ -85,27 +84,31 @@ class MeshServiceOrchestrator( } Logger.i { "Starting mesh service orchestrator" } - val job = Job() - serviceJob = job + val newScope = CoroutineScope(SupervisorJob() + dispatchers.default) + scope = newScope + + // Drop any bytes that piled up in the service's receivedData channel since the last stop(). The channel + // outlives the orchestrator's per-start scope, so without this drain a stop/start cycle would replay stale + // packets ahead of the fresh session's firmware handshake. + radioInterfaceService.resetReceivedBuffer() serviceNotifications.initChannels() connectionManager.updateStatusNotification() // Observe TAK server pref to start/stop - takJob = - takPrefs.isTakServerEnabled - .onEach { isEnabled -> - if (isEnabled && !takServerManager.isRunning.value) { - Logger.i { "TAK Server enabled by preference, starting integration" } - takMeshIntegration.start(scope) - } else if (!isEnabled && takServerManager.isRunning.value) { - Logger.i { "TAK Server disabled by preference, stopping integration" } - takMeshIntegration.stop() - } + takPrefs.isTakServerEnabled + .onEach { isEnabled -> + if (isEnabled && !takServerManager.isRunning.value) { + Logger.i { "TAK Server enabled by preference, starting integration" } + takMeshIntegration.start(newScope) + } else if (!isEnabled && takServerManager.isRunning.value) { + Logger.i { "TAK Server disabled by preference, stopping integration" } + takMeshIntegration.stop() } - .launchIn(scope) + } + .launchIn(newScope) - scope.handledLaunch { + newScope.handledLaunch { // Ensure the per-device database is active before the radio connects. // On Android this is handled by MeshUtilApplication.init(); on Desktop (and any // future KMP host) the orchestrator is the first entry point, so it must initialize @@ -119,18 +122,18 @@ class MeshServiceOrchestrator( radioInterfaceService.receivedData .onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum.value) } - .launchIn(scope) + .launchIn(newScope) radioInterfaceService.connectionError .onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) } - .launchIn(scope) + .launchIn(newScope) // Each action is dispatched in its own supervised coroutine so that a failure in one // action (e.g. a timeout in sendAdminAwait) cannot terminate the collector and silently // drop all subsequent service actions for the rest of the session. serviceRepository.serviceAction - .onEach { action -> scope.handledLaunch { router.actionHandler.onServiceAction(action) } } - .launchIn(scope) + .onEach { action -> newScope.handledLaunch { router.actionHandler.onServiceAction(action) } } + .launchIn(newScope) nodeManager.loadCachedNodeDB() } @@ -142,13 +145,11 @@ class MeshServiceOrchestrator( */ fun stop() { Logger.i { "Stopping mesh service orchestrator" } - takJob?.cancel() - takJob = null // Guard stop() so we don't emit a spurious "stopped" log when TAK was never started if (takServerManager.isRunning.value) { takMeshIntegration.stop() } - serviceJob?.cancel() - serviceJob = null + scope?.cancel() + scope = null } } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index df860a4a2..1bb63971c 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -25,7 +25,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -42,7 +45,7 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.ignoreException +import org.meshtastic.core.common.util.ignoreExceptionSuspend import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState @@ -95,8 +98,13 @@ class SharedRadioInterfaceService( private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value) override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() - private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64) - override val receivedData: SharedFlow = _receivedData + // Unbounded Channel preserves strict FIFO delivery of incoming radio bytes, which the + // firmware handshake depends on (initial config packet ordering). A SharedFlow with + // `launch { emit() }` per packet reorders under concurrent dispatch and breaks config load. + // trySend on an UNLIMITED channel never suspends and never drops, so handleFromRadio can + // remain a non-suspend synchronous callback. + private val _receivedData = Channel(Channel.UNLIMITED) + override val receivedData: Flow = _receivedData.receiveAsFlow() private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -148,6 +156,7 @@ class SharedRadioInterfaceService( } } } + .catch { Logger.e(it) { "devAddr flow crashed" } } .launchIn(processLifecycle.coroutineScope) bluetoothRepository.state @@ -216,7 +225,7 @@ class SharedRadioInterfaceService( processLifecycle.coroutineScope.launch { transportMutex.withLock { - ignoreException { stopTransportLocked() } + ignoreExceptionSuspend { stopTransportLocked() } startTransportLocked() } } @@ -245,7 +254,7 @@ class SharedRadioInterfaceService( } /** Must be called under [transportMutex]. */ - private fun stopTransportLocked() { + private suspend fun stopTransportLocked() { val currentTransport = radioTransport Logger.i { "Stopping transport $currentTransport" } isStarted = false @@ -322,13 +331,28 @@ class SharedRadioInterfaceService( override fun handleFromRadio(bytes: ByteArray) { try { lastDataReceivedMillis = nowMillis - processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) } + // trySend synchronously onto the unbounded Channel so packet order matches arrival + // order. The previous `launch { emit() }` pattern dispatched each packet onto a + // fresh coroutine, letting the scheduler reorder them — which broke the firmware + // config handshake (see PhoneAPI.cpp initial-handshake sequence). + val result = _receivedData.trySend(bytes) + if (result.isFailure) { + Logger.e(result.exceptionOrNull()) { "Failed to enqueue ${bytes.size} received bytes; dropping packet" } + } _meshActivity.tryEmit(MeshActivity.Receive) } catch (t: Throwable) { Logger.e(t) { "handleFromRadio failed while emitting data" } } } + override fun resetReceivedBuffer() { + // Drain any bytes buffered while no collector was attached. Without this, a stop/start cycle + // would replay stale bytes ahead of the next session's firmware handshake, since the channel + // outlives the orchestrator's per-start scope. + @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") + while (_receivedData.tryReceive().isSuccess) Unit + } + override fun onConnect() { // MutableStateFlow.value is thread-safe (backed by atomics) — assign directly rather than // launching a coroutine. The async launch pattern introduced a window where a concurrent diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index ddb7b148f..87109be1e 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -23,13 +23,15 @@ import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode.Companion.atLeast import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.CommandSender @@ -70,8 +72,11 @@ class MeshServiceOrchestratorTest { private val databaseManager: DatabaseManager = mock(MockMode.autofill) private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) + @OptIn(ExperimentalCoroutinesApi::class) private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = CoroutineScope(testDispatcher) + + @OptIn(ExperimentalCoroutinesApi::class) + private val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher) /** Stubs the shared flow dependencies used by every test and returns an orchestrator. */ private fun createOrchestrator( @@ -114,7 +119,7 @@ class MeshServiceOrchestratorTest { takPrefs = takPrefs, databaseManager = databaseManager, connectionManager = connectionManager, - scope = testScope, + dispatchers = dispatchers, ) } @@ -217,4 +222,79 @@ class MeshServiceOrchestratorTest { orchestrator.stop() assertFalse(orchestrator.isRunning) } + + /** + * Regression test for a bug where `stop()` did not actually tear down the FromRadio collectors. Collectors were + * attached to an injected process-wide ServiceScope rather than a per-start scope, so `start() -> stop() -> + * start()` caused duplicate collectors and every FromRadio packet was handled 2x (then 3x, etc.). + */ + @Test + fun testFromRadioCollectorsTornDownOnStopAndRestartedCleanlyOnStart() { + val receivedData = MutableSharedFlow(extraBufferCapacity = 8) + val orchestrator = createOrchestrator(receivedData = receivedData) + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + + orchestrator.start() + val packet1 = byteArrayOf(1, 2, 3) + receivedData.tryEmit(packet1) + verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet1, null) } + + orchestrator.stop() + val packet2 = byteArrayOf(4, 5, 6) + receivedData.tryEmit(packet2) + // After stop(), the collector must be gone - the handler should not be invoked for packet2. + verifySuspend(exactly(0)) { messageProcessor.handleFromRadio(packet2, null) } + + orchestrator.start() + val packet3 = byteArrayOf(7, 8, 9) + receivedData.tryEmit(packet3) + // After restart, a single fresh collector must process packet3 exactly once (not twice). + verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet3, null) } + + orchestrator.stop() + } + + /** + * Regression test for a channel-buffer-replay bug: the production [RadioInterfaceService] buffers inbound bytes in + * a process-lifetime `Channel(UNLIMITED)`. Between `stop()` and the next `start()`, any bytes that arrive sit in + * the channel and would be replayed to the fresh collector — prepending stale packets to the next session's + * firmware handshake. `start()` must call [RadioInterfaceService.resetReceivedBuffer] before attaching the + * collector. + */ + @Test + fun testStartDrainsReceivedBufferBeforeAttachingCollector() { + val orchestrator = createOrchestrator() + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + + orchestrator.start() + orchestrator.stop() + orchestrator.start() + + // resetReceivedBuffer must be invoked at least once per start() (twice total for two starts). + verify(atLeast(2)) { radioInterfaceService.resetReceivedBuffer() } + + orchestrator.stop() + } + + /** Additional regression: after many start/stop cycles, collectors must not accumulate. */ + @Test + fun testRepeatedStartStopDoesNotAccumulateCollectors() { + val receivedData = MutableSharedFlow(extraBufferCapacity = 8) + val orchestrator = createOrchestrator(receivedData = receivedData) + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + + repeat(5) { + orchestrator.start() + orchestrator.stop() + } + + orchestrator.start() + val packet = byteArrayOf(42) + receivedData.tryEmit(packet) + + // Despite six total start() calls, only the most recent collector is live. + verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet, null) } + + orchestrator.stop() + } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt index 31248ec41..0a47321d6 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt @@ -18,12 +18,15 @@ package org.meshtastic.core.takserver import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -58,6 +61,12 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager private val _inboundMessages = MutableSharedFlow() override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow() + // Unbounded channel preserves FIFO ordering of inbound CoT messages under load. + // onMessage is a non-suspend callback, so we trySend (always succeeds for UNLIMITED) + // and a single consumer coroutine drains into _inboundMessages in order. + private var inboundChannel: Channel? = null + private var inboundDrainJob: Job? = null + private var lastBroadcastPositions = mutableMapOf() override fun start(scope: CoroutineScope) { @@ -68,8 +77,11 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager } scope.launch { - // Wire up inbound message handler BEFORE starting so no messages are lost - takServer.onMessage = { cotMessage -> scope.launch { _inboundMessages.emit(cotMessage) } } + // Wire up inbound message handler BEFORE starting so no messages are lost. + val channel = Channel(Channel.UNLIMITED) + inboundChannel = channel + inboundDrainJob = scope.launch { channel.consumeAsFlow().collect { _inboundMessages.emit(it) } } + takServer.onMessage = { cotMessage -> channel.trySend(cotMessage) } val result = takServer.start(scope) if (result.isSuccess) { @@ -79,6 +91,10 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" } // Clear onMessage if start failed so we don't hold a reference unnecessarily takServer.onMessage = null + inboundDrainJob?.cancel() + inboundDrainJob = null + channel.close() + inboundChannel = null } } } @@ -86,6 +102,10 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager override fun stop() { takServer.stop() takServer.onMessage = null + inboundChannel?.close() + inboundChannel = null + inboundDrainJob?.cancel() + inboundDrainJob = null _isRunning.value = false scope = null Logger.i { "TAK Server stopped" } diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 25e1a3d91..8d0b5837a 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -32,8 +32,8 @@ kotlin { // Heavy modules (database, data, domain) should depend on core:testing, not vice versa. api(projects.core.model) api(projects.core.repository) - api(projects.core.database) - api(projects.core.ble) + implementation(projects.core.database) + implementation(projects.core.ble) implementation(projects.core.datastore) implementation(libs.androidx.room.runtime) api(libs.kermit) diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 9a703004c..0eb120fbe 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -237,15 +237,6 @@ class FakeMeshPrefs : MeshPrefs { deviceAddress.value = address } - private val provideLocation = mutableMapOf>() - - override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = - provideLocation.getOrPut(nodeNum) { MutableStateFlow(true) } - - override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) { - provideLocation.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide - } - private val lastRequest = mutableMapOf>() override fun getStoreForwardLastRequest(address: String?): StateFlow = diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt index 9f11a2bc6..d3f8dc71e 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt @@ -18,10 +18,13 @@ package org.meshtastic.core.testing import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId @@ -48,8 +51,10 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main private val _currentDeviceAddressFlow = MutableStateFlow(null) override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow - private val _receivedData = MutableSharedFlow() - override val receivedData: SharedFlow = _receivedData + // Use an unbounded Channel to mirror SharedRadioInterfaceService semantics. A MutableSharedFlow would + // hide the stop/start backlog bug that motivated the resetReceivedBuffer() API. + private val _receivedData = Channel(Channel.UNLIMITED) + override val receivedData: Flow = _receivedData.receiveAsFlow() private val _meshActivity = MutableSharedFlow() override val meshActivity: SharedFlow = _meshActivity @@ -88,13 +93,18 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main } override fun handleFromRadio(bytes: ByteArray) { - // In a real implementation, this would emit to receivedData + _receivedData.trySend(bytes) + } + + override fun resetReceivedBuffer() { + @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") + while (_receivedData.tryReceive().isSuccess) Unit } // --- Helper methods for testing --- - suspend fun emitFromRadio(bytes: ByteArray) { - _receivedData.emit(bytes) + fun emitFromRadio(bytes: ByteArray) { + _receivedData.trySend(bytes) } fun setConnectionState(state: ConnectionState) { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt index 66afa69be..492802426 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt @@ -32,7 +32,7 @@ class FakeRadioTransport : RadioTransport { keepAliveCalled = true } - override fun close() { + override suspend fun close() { closeCalled = true } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index fdf7cee5c..58caf800b 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -127,7 +127,10 @@ compose.desktop { isEnabled.set(true) obfuscate.set(false) // Open-source project — obfuscation adds no value optimize.set(true) - configurationFiles.from(project.file("proguard-rules.pro")) + configurationFiles.from( + rootProject.file("config/proguard/shared-rules.pro"), + project.file("proguard-rules.pro"), + ) } nativeDistributions { diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index ef1576555..9e23e32c7 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -4,202 +4,56 @@ # Open-source project: we rely on tree-shaking (unused code removal) for size # reduction. Obfuscation is disabled in build.gradle.kts (obfuscate.set(false)). # -# Key libraries requiring keep-rules (reflection, JNI, code generation): -# Koin (DI via reflection), kotlinx-serialization (generated serializers), -# Wire protobuf (ADAPTER reflection), Room KMP (generated DB + converters), -# Ktor (Java engine + ServiceLoader), Kable BLE, Coil, Compose Multiplatform -# resources, SQLite bundled (JNI), AboutLibraries. +# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, +# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, +# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in +# config/proguard/shared-rules.pro and are wired in by this module's +# build.gradle.kts. This file holds only desktop/JVM-specific rules. # ============================================================================ # ---- General ---------------------------------------------------------------- -# Preserve line numbers for meaningful stack traces --keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions - # Suppress notes about duplicate resource files (common in fat JARs) -dontnote ** +# Disable ProGuard optimization passes. Tree-shaking (unused code removal) still +# runs — only method-body rewrites and call-site transformations are suppressed. +# +# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on +# Composer.() and ComposerImpl.(), plus -assumevalues on +# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives +# let the optimizer rewrite *call sites* (class-init triggers, flag reads) even +# when the target classes are preserved by -keep rules. The result is that the +# Compose recomposer/frame-clock/animation state machines silently freeze on +# their first frame in release builds. -dontoptimize is the only directive that +# disables processing of -assumenosideeffects/-assumevalues. The desktop compose +# build sets optimize.set(true), so this applies here as well as to R8. See #5146. +-dontoptimize + # Do not parse/rewrite Kotlin metadata during shrinking/optimization. # ProGuard's KotlinShrinker cannot handle the metadata produced by Compose # Multiplatform 1.11.x + Kotlin 2.3.x, causing a NullPointerException. # Since we disable obfuscation (class names remain stable), metadata references # stay valid and do not need rewriting. The annotations themselves are preserved # by -keepattributes *Annotation*. +# +# NOTE: -dontprocesskotlinmetadata is a ProGuard-only directive; R8 does not +# recognize it, which is why it lives in the desktop-only file. -dontprocesskotlinmetadata # ---- Entry point ------------------------------------------------------------ -keep class org.meshtastic.desktop.MainKt { *; } -# ---- Kotlin / Coroutines --------------------------------------------------- +# ---- Ktor Java engine (desktop-only; Android uses OkHttp) ------------------- -# Keep Kotlin metadata for reflection-dependent libraries --keep class kotlin.Metadata { *; } --keep class kotlin.reflect.** { *; } - -# Coroutines internals --dontwarn kotlinx.coroutines.** --keep class kotlinx.coroutines.** { *; } --keep class kotlin.coroutines.Continuation { *; } - -# ---- Koin DI (reflection-based injection) ----------------------------------- - -# Koin core — uses reflection to instantiate definitions --keep class org.koin.** { *; } --dontwarn org.koin.** - -# Keep all Koin-annotated @Module / @ComponentScan classes and their generated -# counterparts so Koin K2 plugin output survives tree-shaking. --keep @org.koin.core.annotation.Module class * { *; } --keep @org.koin.core.annotation.ComponentScan class * { *; } --keep @org.koin.core.annotation.Single class * { *; } --keep @org.koin.core.annotation.Factory class * { *; } - -# Generated Koin module extensions (K2 plugin output) --keep class org.meshtastic.**.di.** { *; } - -# ---- kotlinx-serialization -------------------------------------------------- - -# The serialization plugin generates companion $serializer classes and -# serializer() factory methods that are invoked reflectively. --keepattributes RuntimeVisibleAnnotations --keep class kotlinx.serialization.** { *; } --dontwarn kotlinx.serialization.** - -# Keep @Serializable classes and their generated serializers --keepclassmembers @kotlinx.serialization.Serializable class ** { - # Companion object that holds the serializer() factory - static ** Companion; - kotlinx.serialization.KSerializer serializer(...); -} --keepclassmembers class **.$serializer { *; } --keep class **.$serializer { *; } --keepclasseswithmembers class ** { - kotlinx.serialization.KSerializer serializer(...); -} - -# ---- Wire protobuf ---------------------------------------------------------- - -# Wire generates ADAPTER companion objects accessed via reflection --keep class com.squareup.wire.** { *; } --dontwarn com.squareup.wire.** - -# All generated proto message classes --keep class org.meshtastic.proto.** { *; } --keep class meshtastic.** { *; } - -# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs) --dontwarn android.os.Parcel** --dontwarn android.os.Parcelable** - -# ---- Room KMP --------------------------------------------------------------- - -# Preserve generated database constructors (required for Room's reflective init) --keep class * extends androidx.room3.RoomDatabase { (); } --keep class * implements androidx.room3.RoomDatabaseConstructor { *; } - -# Keep the expect/actual MeshtasticDatabaseConstructor --keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } --keep class org.meshtastic.core.database.MeshtasticDatabase { *; } - -# Room DAOs — Room generates implementations at compile time; keep interfaces --keep class org.meshtastic.core.database.dao.** { *; } - -# Room Entities — accessed via reflection for column mapping --keep class org.meshtastic.core.database.entity.** { *; } - -# Room TypeConverters — invoked reflectively --keep class org.meshtastic.core.database.Converters { *; } - -# Room generated _Impl classes --keep class **_Impl { *; } - -# ---- SQLite bundled (JNI) --------------------------------------------------- - --keep class androidx.sqlite.** { *; } --dontwarn androidx.sqlite.** - -# ---- Ktor (Java engine + ServiceLoader + content negotiation) --------------- - -# Ktor uses ServiceLoader and reflection for engine/plugin discovery --keep class io.ktor.** { *; } --dontwarn io.ktor.** - -# Keep ServiceLoader metadata files --keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } - -# Java HTTP client engine -keep class io.ktor.client.engine.java.** { *; } -# ---- Coil (image loading) --------------------------------------------------- - --keep class coil3.** { *; } --dontwarn coil3.** - -# ---- Kable BLE -------------------------------------------------------------- - --keep class com.juul.kable.** { *; } --dontwarn com.juul.kable.** - -# ---- Compose Multiplatform resources ---------------------------------------- - -# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.) --keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.** { *; } - - -# ---- AboutLibraries --------------------------------------------------------- - --keep class com.mikepenz.aboutlibraries.** { *; } --dontwarn com.mikepenz.aboutlibraries.** - -# ---- Multiplatform Markdown Renderer ---------------------------------------- - --keep class com.mikepenz.markdown.** { *; } --dontwarn com.mikepenz.markdown.** - -# ---- QR Code Kotlin --------------------------------------------------------- - --keep class io.github.g0dkar.qrcode.** { *; } --dontwarn io.github.g0dkar.qrcode.** --keep class qrcode.** { *; } --dontwarn qrcode.** - -# ---- Kermit logging ---------------------------------------------------------- - --keep class co.touchlab.kermit.** { *; } --dontwarn co.touchlab.kermit.** - -# ---- Okio ------------------------------------------------------------------- - --dontwarn okio.** --keep class okio.** { *; } - -# ---- DataStore -------------------------------------------------------------- - --keep class androidx.datastore.** { *; } --dontwarn androidx.datastore.** - -# ---- Paging ----------------------------------------------------------------- - --keep class androidx.paging.** { *; } --dontwarn androidx.paging.** - -# ---- Lifecycle / Navigation / ViewModel (JetBrains forks) ------------------- - --keep class androidx.lifecycle.** { *; } --keep class androidx.navigation3.** { *; } --dontwarn androidx.lifecycle.** --dontwarn androidx.navigation3.** - -# ---- Meshtastic application code -------------------------------------------- +# ---- Meshtastic desktop host shell ------------------------------------------ # Keep all desktop module classes (thin host shell — not worth tree-shaking) -keep class org.meshtastic.desktop.** { *; } -# Core model classes (used in serialization, Room, and Koin injection) --keep class org.meshtastic.core.model.** { *; } - # ---- JVM runtime suppression ------------------------------------------------ -dontwarn java.lang.reflect.** diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 80e049bce..026f0a100 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -60,6 +60,7 @@ import io.ktor.client.HttpClient import kotlinx.coroutines.flow.first import okio.Path.Companion.toPath import org.jetbrains.compose.resources.decodeToSvgPainter +import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.core.context.startKoin import org.meshtastic.core.common.BuildConfigProvider @@ -70,6 +71,10 @@ import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.desktop_tray_quit +import org.meshtastic.core.resources.desktop_tray_show +import org.meshtastic.core.resources.desktop_tray_tooltip import org.meshtastic.core.service.MeshServiceOrchestrator import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.viewmodel.UIViewModel @@ -216,11 +221,11 @@ private fun ApplicationScope.MeshtasticDesktopApp( Tray( state = trayState, icon = trayIcon, - tooltip = "Meshtastic Desktop", + tooltip = stringResource(Res.string.desktop_tray_tooltip), onAction = { isAppVisible = true }, menu = { - Item("Show Meshtastic", onClick = { isAppVisible = true }) - Item("Quit", onClick = ::exitApplication) + Item(stringResource(Res.string.desktop_tray_show), onClick = { isAppVisible = true }) + Item(stringResource(Res.string.desktop_tray_quit), onClick = ::exitApplication) }, ) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index 309fff7da..4cda00251 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -22,6 +22,7 @@ import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.desktop_notification_title import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.low_battery_message import org.meshtastic.core.resources.low_battery_title @@ -141,7 +142,7 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat override fun showClientNotification(clientNotification: ClientNotification) { notificationManager.dispatch( Notification( - title = "Meshtastic", + title = getString(Res.string.desktop_notification_title), message = clientNotification.message, category = Notification.Category.Alert, id = clientNotification.toString().hashCode(), diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index 220b21d05..985a76987 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -80,6 +80,10 @@ class NoopRadioInterfaceService : RadioInterfaceService { logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)") } + override fun resetReceivedBuffer() { + // No-op: this stub never buffers bytes. + } + override fun connect() { logWarn("NoopRadioInterfaceService.connect()") } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt index ecdaeb3c3..4249cd625 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt @@ -18,14 +18,15 @@ package org.meshtastic.feature.connections.domain.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.util.safeCatchingAll import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.demo_mode +import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.meshtastic import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.DiscoveredDevices @@ -49,7 +50,7 @@ class CommonGetDiscoveredDevicesUseCase( tcpServices, recentList, -> - val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic") + val defaultName = safeCatchingAll { getStringSuspend(Res.string.meshtastic) }.getOrDefault("Meshtastic") processTcpServices(tcpServices, recentList, defaultName) } @@ -71,7 +72,7 @@ class CommonGetDiscoveredDevicesUseCase( usbList + if (showMock) { val demoModeLabel = - runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode") + safeCatchingAll { getStringSuspend(Res.string.demo_mode) }.getOrDefault("Demo Mode") listOf(DeviceListEntry.Mock(demoModeLabel)) } else { emptyList() diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index 8499d4e20..a4d8ecdd8 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,15 +76,11 @@ fun DeviceListItem( ) { // Throttle the RSSI updates to match the connected device polling rate var displayedRssi by remember { mutableIntStateOf(rssi ?: 0) } - LaunchedEffect(rssi) { - if (displayedRssi == 0) { - displayedRssi = rssi ?: 0 - } - } + val currentRssi by rememberUpdatedState(rssi) LaunchedEffect(Unit) { while (true) { delay(RSSI_UPDATE_RATE_MS) - displayedRssi = rssi ?: 0 + displayedRssi = currentRssi ?: 0 } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt index 8565b3dcc..8035774c4 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -48,7 +47,7 @@ class BleOtaTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) : UnifiedOtaProtocol { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 58c09f16a..82e91413d 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository @@ -67,7 +68,7 @@ private const val GATT_RELEASE_DELAY_MS = 1000L * * All platform I/O (file reading, content-resolver imports) is delegated to [FirmwareFileHandler]. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @Single class Esp32OtaUpdateHandler( private val firmwareRetriever: FirmwareRetriever, @@ -76,6 +77,7 @@ class Esp32OtaUpdateHandler( private val nodeRepository: NodeRepository, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { /** Entry point for FirmwareUpdateHandler interface. Routes to BLE (MAC with colons) or WiFi (IP without). */ @@ -102,7 +104,7 @@ class Esp32OtaUpdateHandler( hardware = hardware, updateState = updateState, firmwareUri = firmwareUri, - transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address) }, + transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatchers.default) }, rebootMode = 1, connectionAttempts = 5, ) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt index 53e8ed977..d21cc15ea 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt @@ -167,7 +167,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In override suspend fun close() { withContext(ioDispatcher) { - runCatching { + safeCatching { socket?.close() selectorManager?.close() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index 3e673461b..a2eb5a7a4 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -28,6 +28,7 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.resources.Res @@ -70,6 +71,7 @@ class SecureDfuHandler( private val radioController: RadioController, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { @Suppress("LongMethod") @@ -108,7 +110,7 @@ class SecureDfuHandler( var transport: SecureDfuTransport? = null var completed = false try { - transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target) + transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) transport.triggerButtonlessDfu().onFailure { e -> Logger.w(e) { "DFU: Buttonless trigger failed ($e) — device may already be in DFU mode" } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index 10320e6e5..42e92c8ac 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel @@ -67,7 +66,7 @@ class SecureDfuTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) private val bleConnection = connectionFactory.create(transportScope, "Secure DFU") @@ -252,7 +251,7 @@ class SecureDfuTransport( * accept a fresh DFU session. */ suspend fun abort() { - runCatching { + safeCatching { bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) service.write(controlChar, byteArrayOf(DfuOpcode.ABORT), BleWriteType.WITH_RESPONSE) @@ -264,7 +263,7 @@ class SecureDfuTransport( /** Disconnect from the DFU target and cancel the transport coroutine scope. */ suspend fun close() { - runCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } + safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } transportScope.cancel() } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt index 723fed82f..0a26fd13e 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt @@ -20,9 +20,11 @@ import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository @@ -59,6 +61,12 @@ class DefaultFirmwareUpdateManagerTest { private val bleScanner: BleScanner = mock(MockMode.autofill) private val bleConnectionFactory: BleConnectionFactory = mock(MockMode.autofill) private val firmwareRetriever = FirmwareRetriever(fileHandler) + private val dispatchers = + CoroutineDispatchers( + io = Dispatchers.Unconfined, + main = Dispatchers.Unconfined, + default = Dispatchers.Unconfined, + ) private val secureDfuHandler = SecureDfuHandler( @@ -67,6 +75,7 @@ class DefaultFirmwareUpdateManagerTest { radioController = radioController, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, + dispatchers = dispatchers, ) private val usbUpdateHandler = @@ -84,6 +93,7 @@ class DefaultFirmwareUpdateManagerTest { nodeRepository = nodeRepository, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, + dispatchers = dispatchers, ) private fun createManager(address: String?): DefaultFirmwareUpdateManager { diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 2793f3625..c33a6f353 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.meshtastic.kmp.feature) alias(libs.plugins.meshtastic.kotlinx.serialization) + id("meshtastic.kmp.jvm.android") } kotlin { diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt index 499874e26..723448897 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt @@ -23,7 +23,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -private const val SDK_INT_ANDROID_16 = 37 +private val SDK_INT_ANDROID_16 = Build.VERSION_CODES.BAKLAVA @OptIn(ExperimentalPermissionsApi::class) @Composable diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt similarity index 100% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt rename to feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt deleted file mode 100644 index 0a35599f5..000000000 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings.navigation - -import org.meshtastic.core.navigation.SettingsRoute - -actual fun getAboutLibrariesJson(): String = - SettingsRoute::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt index 793482ba2..ee40bd60b 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt @@ -26,14 +26,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.LocalStats @@ -78,12 +76,10 @@ data class LocalStatsWidgetUiState( val updateTimeMillis: Long = 0, ) +private const val WIDGET_SUBSCRIPTION_TIMEOUT_MS = 5_000L + @Single -class LocalStatsWidgetStateProvider( - nodeRepository: NodeRepository, - serviceRepository: ServiceRepository, - appWidgetUpdater: AppWidgetUpdater, -) { +class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -105,8 +101,11 @@ class LocalStatsWidgetStateProvider( mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode) } .distinctUntilChanged() - .onEach { appWidgetUpdater.updateAll() } - .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState()) + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(WIDGET_SUBSCRIPTION_TIMEOUT_MS), + initialValue = LocalStatsWidgetUiState(), + ) private data class StateInput( val connectionState: ConnectionState, diff --git a/feature/wifi-provision/build.gradle.kts b/feature/wifi-provision/build.gradle.kts index 3ce123dec..84b8199a5 100644 --- a/feature/wifi-provision/build.gradle.kts +++ b/feature/wifi-provision/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { commonMain.dependencies { implementation(projects.core.ble) implementation(projects.core.common) + implementation(projects.core.di) implementation(projects.core.navigation) implementation(projects.core.resources) implementation(projects.core.ui) diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt index 9e1177be8..6dbb8c676 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.feature.wifiprovision.domain.NymeaWifiService import org.meshtastic.feature.wifiprovision.model.ProvisionResult import org.meshtastic.feature.wifiprovision.model.WifiNetwork @@ -106,6 +107,7 @@ sealed interface WifiProvisionError { class WifiProvisionViewModel( private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, + private val dispatchers: CoroutineDispatchers, ) : ViewModel() { private val _uiState = MutableStateFlow(WifiProvisionUiState()) @@ -127,7 +129,7 @@ class WifiProvisionViewModel( _uiState.update { it.copy(phase = WifiProvisionUiState.Phase.ConnectingBle, error = null) } viewModelScope.launch { - val nymeaService = NymeaWifiService(bleScanner, bleConnectionFactory) + val nymeaService = NymeaWifiService(bleScanner, bleConnectionFactory, dispatchers.default) service = nymeaService nymeaService diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt index 1723e6df6..75dc15256 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -67,7 +66,7 @@ import org.meshtastic.feature.wifiprovision.model.WifiNetwork class NymeaWifiService( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, - dispatcher: CoroutineDispatcher = Dispatchers.Default, + dispatcher: CoroutineDispatcher, ) { private val serviceScope = CoroutineScope(SupervisorJob() + dispatcher) diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt index 65798a13b..0ee5bb0ec 100644 --- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.testing.FakeBleConnection import org.meshtastic.core.testing.FakeBleConnectionFactory import org.meshtastic.core.testing.FakeBleDevice @@ -62,7 +63,15 @@ class WifiProvisionViewModelTest { scanner = FakeBleScanner() connection = FakeBleConnection() viewModel = - WifiProvisionViewModel(bleScanner = scanner, bleConnectionFactory = FakeBleConnectionFactory(connection)) + WifiProvisionViewModel( + bleScanner = scanner, + bleConnectionFactory = FakeBleConnectionFactory(connection), + dispatchers = CoroutineDispatchers( + io = testDispatcher, + main = testDispatcher, + default = testDispatcher, + ), + ) } @AfterTest diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ecd560a8..668ed133a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,11 +34,6 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" -# Skiko is an internal CMP implementation detail. Pin it to the version shipped by CMP to -# silence the "Skiko dependencies' versions are incompatible" warning emitted when transitive -# dependencies (e.g. coil3) carry an older skiko requirement that Gradle then upgrades to the -# CMP-bundled version. Bump this together with compose-multiplatform. -skiko = "0.144.5" compose-multiplatform-material3 = "1.11.0-alpha06" # AndroidX Compose test/tracing artifacts share a version track with CMP but are resolved # independently by Maven. Pinning them to their own ref prevents Renovate from bumping the @@ -153,7 +148,6 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.12.0 firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } location-services = { module = "com.google.android.gms:play-services-location", version = "21.3.0" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } -koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } @@ -257,7 +251,6 @@ koin-gradlePlugin = { module = "io.insert-koin.compiler.plugin:io.insert-koin.co mokkery-gradlePlugin = { module = "dev.mokkery:mokkery-gradle", version.ref = "mokkery" } kover-gradlePlugin = { module = "org.jetbrains.kotlinx.kover:org.jetbrains.kotlinx.kover.gradle.plugin", version.ref = "kover" } ksp-gradlePlugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "devtools-ksp" } -secrets-gradlePlugin = {module = "com.google.android.secrets-gradle-plugin:com.google.android.secrets-gradle-plugin.gradle.plugin", version = "1.1.0"} serialization-gradlePlugin = { module = "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin", version.ref = "kotlin" } spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" } @@ -300,14 +293,12 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" } # Meshtastic -meshtastic-analytics = { id = "meshtastic.analytics" } meshtastic-android-application = { id = "meshtastic.android.application" } meshtastic-android-application-compose = { id = "meshtastic.android.application.compose" } meshtastic-android-application-flavors = { id = "meshtastic.android.application.flavors" } meshtastic-android-library = { id = "meshtastic.android.library" } meshtastic-android-library-compose = { id = "meshtastic.android.library.compose" } meshtastic-android-library-flavors = { id = "meshtastic.android.library.flavors" } -meshtastic-android-lint = { id = "meshtastic.android.lint" } meshtastic-android-room = { id = "meshtastic.android.room" } meshtastic-detekt = { id = "meshtastic.detekt" } meshtastic-koin = { id = "meshtastic.koin" }