chore: review-cleanup fleet (audit + fix + hardening) (#5158)

This commit is contained in:
James Rich 2026-04-16 19:02:59 -05:00 committed by GitHub
parent 872c566ef1
commit 17e69c6d4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 784 additions and 459 deletions

12
.github/lsp.json vendored Normal file
View file

@ -0,0 +1,12 @@
{
"lspServers": {
"kotlin": {
"command": "kotlin-language-server",
"args": [],
"fileExtensions": {
".kt": "kotlin",
".kts": "kotlin"
}
}
}
}

View file

@ -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

View file

@ -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 <branch-name> 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
`<git_and_prs>`:
| Prefix | Use for |
| :--- | :--- |
| `feat/<scope>` | New user-visible behavior |
| `fix/<scope>` | Bug fixes |
| `refactor/<scope>` | Code structure changes, no behavior change |
| `chore/<scope>` | Tooling, deps, CI, cleanup |
| `docs/<scope>` | 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 <NNNN> # 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 `<copilot_cli_workflow>`.

View file

@ -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.
</context_and_memory>
@ -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.
</rules>
<copilot_cli_workflow>
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.
</copilot_cli_workflow>
<git_and_prs>
- **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.

View file

@ -6,4 +6,4 @@
- **Think First:** Always outline your step-by-step reasoning inside `<thinking>` 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 `<copilot_cli_workflow>` section.

View file

@ -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 { <init>(); }
# ---- 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.** { *; }

View file

@ -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))
}
}

View file

@ -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)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Project> {
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<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig {
vectorDrawables.useSupportLibrary = true
}
defaultConfig { vectorDrawables.useSupportLibrary = true }
buildTypes {
getByName("release") {
@ -50,7 +45,8 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
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<Project> {
}
}
buildFeatures {
buildConfig = true
}
buildFeatures { buildConfig = true }
}
configureTestOptions()
}

View file

@ -32,10 +32,12 @@ class KmpLibraryComposeConventionPlugin : Plugin<Project> {
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
extensions.configure<KotlinMultiplatformExtension> {
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()

View file

@ -14,11 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Project> {
apply(plugin = "org.gradle.test-retry")
apply(plugin = libs.plugin("mokkery").get().pluginId)
extensions.configure<MokkeryGradleExtension> { stubs.allowConcreteClassInstantiation.set(true) }
configureKotlinMultiplatform()
configureKmpTestDependencies()
configureTestOptions()

View file

@ -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") {

View file

@ -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 { <init>(); }
-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.** { *; }

View file

@ -95,3 +95,18 @@ inline fun <T, R> T.safeCatching(block: T.() -> R): Result<R> = 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 <T> safeCatchingAll(block: () -> T): Result<T> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Result.failure(t)
}

View file

@ -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<MeshDataHandler>,
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val uiPrefs: UiPrefs,
private val databaseManager: DatabaseManager,
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
@ -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

View file

@ -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<MeshPacket>()
// 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<MeshPacket>(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<Int, CompletableDeferred<Boolean>>()
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")

View file

@ -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 }
}
}

View file

@ -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<MeshDataHandler>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
private val messageProcessor = mock<MeshMessageProcessor>(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)

View file

@ -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
}

View file

@ -58,6 +58,8 @@ kotlin {
implementation(libs.androidx.test.runner)
}
}
commonTest.dependencies { implementation(projects.core.testing) }
}
}

View file

@ -32,5 +32,7 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.kermit)
}
commonTest.dependencies { implementation(projects.core.testing) }
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}

View file

@ -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) {

View file

@ -144,7 +144,7 @@ class MockRadioTransport(
}
}
override fun close() {
override suspend fun close() {
Logger.i { "Closing the mock transport" }
}

View file

@ -30,7 +30,7 @@ class NopRadioTransport(val address: String) : RadioTransport {
// No-op
}
override fun close() {
override suspend fun close() {
// No-op
}
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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

View file

@ -19,19 +19,31 @@ package org.meshtastic.core.prefs
import kotlinx.atomicfu.AtomicRef
import kotlinx.collections.immutable.PersistentMap
internal inline fun <K, V> cachedFlow(cache: AtomicRef<PersistentMap<K, V>>, 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 <K, V> cachedFlow(
cache: AtomicRef<PersistentMap<K, Lazy<V>>>,
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)
}

View file

@ -42,7 +42,7 @@ class MapConsentPrefsImpl(
) : MapConsentPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val consentFlows = atomic(persistentMapOf<Int?, StateFlow<Boolean>>())
private val consentFlows = atomic(persistentMapOf<Int?, Lazy<StateFlow<Boolean>>>())
override fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean> = cachedFlow(consentFlows, nodeNum) {
val key = booleanPreferencesKey(nodeNum.toString())

View file

@ -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<Int?, StateFlow<Boolean>>())
private val storeForwardFlows = atomic(persistentMapOf<String?, StateFlow<Int>>())
private val storeForwardFlows = atomic(persistentMapOf<String?, Lazy<StateFlow<Int>>>())
override val deviceAddress: StateFlow<String?> =
dataStore.data
@ -65,15 +63,6 @@ class MeshPrefsImpl(
}
}
override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow<Boolean> = 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<Int> = 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 {

View file

@ -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<Int, StateFlow<Boolean>>())
private val provideNodeLocationFlows = atomic(persistentMapOf<Int, Lazy<StateFlow<Boolean>>>())
override val appIntroCompleted: StateFlow<Boolean> =
dataStore.data.map { it[KEY_APP_INTRO_COMPLETED] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)

View file

@ -213,10 +213,6 @@ interface MeshPrefs {
fun setDeviceAddress(address: String?)
fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow<Boolean>
fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean)
fun getStoreForwardLastRequest(address: String?): StateFlow<Int>
fun setStoreForwardLastRequest(address: String?, timestamp: Int)

View file

@ -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<ByteArray>
/**
* 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<ByteArray>
/** Flow of radio activity events. */
val meshActivity: SharedFlow<MeshActivity>
/**
* 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)

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -1261,4 +1261,8 @@
<string name="wifi_provision_ssid_placeholder">Enter or select a network</string>
<string name="wifi_provision_status_applied">WiFi configured successfully!</string>
<string name="wifi_provision_status_failed">Failed to apply WiFi configuration</string>
<string name="desktop_tray_tooltip">Meshtastic Desktop</string>
<string name="desktop_tray_show">Show Meshtastic</string>
<string name="desktop_tray_quit">Quit</string>
<string name="desktop_notification_title">Meshtastic</string>
</resources>

View file

@ -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
}
}

View file

@ -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<String?>(radioPrefs.devAddr.value)
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
override val receivedData: SharedFlow<ByteArray> = _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<ByteArray>(Channel.UNLIMITED)
override val receivedData: Flow<ByteArray> = _receivedData.receiveAsFlow()
private val _meshActivity =
MutableSharedFlow<MeshActivity>(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

View file

@ -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<ByteArray>(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<ByteArray>(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()
}
}

View file

@ -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<CoTMessage>()
override val inboundMessages: SharedFlow<CoTMessage> = _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<CoTMessage>? = null
private var inboundDrainJob: Job? = null
private var lastBroadcastPositions = mutableMapOf<Int, Int>()
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<CoTMessage>(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" }

View file

@ -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)

View file

@ -237,15 +237,6 @@ class FakeMeshPrefs : MeshPrefs {
deviceAddress.value = address
}
private val provideLocation = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow<Boolean> =
provideLocation.getOrPut(nodeNum) { MutableStateFlow(true) }
override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) {
provideLocation.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide
}
private val lastRequest = mutableMapOf<String?, MutableStateFlow<Int>>()
override fun getStoreForwardLastRequest(address: String?): StateFlow<Int> =

View file

@ -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<String?>(null)
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow
private val _receivedData = MutableSharedFlow<ByteArray>()
override val receivedData: SharedFlow<ByteArray> = _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<ByteArray>(Channel.UNLIMITED)
override val receivedData: Flow<ByteArray> = _receivedData.receiveAsFlow()
private val _meshActivity = MutableSharedFlow<MeshActivity>()
override val meshActivity: SharedFlow<MeshActivity> = _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) {

View file

@ -32,7 +32,7 @@ class FakeRadioTransport : RadioTransport {
keepAliveCalled = true
}
override fun close() {
override suspend fun close() {
closeCalled = true
}
}

View file

@ -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 {

View file

@ -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.<clinit>() and ComposerImpl.<clinit>(), 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 { <init>(); }
-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.**

View file

@ -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)
},
)

View file

@ -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(),

View file

@ -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()")
}

View file

@ -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()

View file

@ -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
}
}

View file

@ -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)

View file

@ -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,
)

View file

@ -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()
}

View file

@ -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" }

View file

@ -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()
}

View file

@ -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 {

View file

@ -18,6 +18,7 @@
plugins {
alias(libs.plugins.meshtastic.kmp.feature)
alias(libs.plugins.meshtastic.kotlinx.serialization)
id("meshtastic.kmp.jvm.android")
}
kotlin {

View file

@ -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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import org.meshtastic.core.navigation.SettingsRoute
actual fun getAboutLibrariesJson(): String =
SettingsRoute::class.java.getResource("/aboutlibraries.json")?.readText() ?: ""

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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" }