mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
chore: review-cleanup fleet (audit + fix + hardening) (#5158)
This commit is contained in:
parent
872c566ef1
commit
17e69c6d4c
68 changed files with 784 additions and 459 deletions
12
.github/lsp.json
vendored
Normal file
12
.github/lsp.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"lspServers": {
|
||||
"kotlin": {
|
||||
"command": "kotlin-language-server",
|
||||
"args": [],
|
||||
"fileExtensions": {
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
.github/workflows/models_pr_triage.yml
vendored
14
.github/workflows/models_pr_triage.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
79
.skills/new-branch/SKILL.md
Normal file
79
.skills/new-branch/SKILL.md
Normal 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>`.
|
||||
28
AGENTS.md
28
AGENTS.md
|
|
@ -18,6 +18,7 @@ You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Mes
|
|||
- `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties.
|
||||
- `.skills/implement-feature/` - Step-by-step feature workflow.
|
||||
- `.skills/code-review/` - PR validation checklist.
|
||||
- `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs.
|
||||
- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch.
|
||||
</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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
37
app/proguard-rules.pro
vendored
37
app/proguard-rules.pro
vendored
|
|
@ -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.** { *; }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
179
config/proguard/shared-rules.pro
Normal file
179
config/proguard/shared-rules.pro
Normal 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.** { *; }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ kotlin {
|
|||
implementation(libs.androidx.test.runner)
|
||||
}
|
||||
}
|
||||
|
||||
commonTest.dependencies { implementation(projects.core.testing) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,5 +32,7 @@ kotlin {
|
|||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
|
||||
commonTest.dependencies { implementation(projects.core.testing) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class MockRadioTransport(
|
|||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
override suspend fun close() {
|
||||
Logger.i { "Closing the mock transport" }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class NopRadioTransport(val address: String) : RadioTransport {
|
|||
// No-op
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
override suspend fun close() {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class FakeRadioTransport : RadioTransport {
|
|||
keepAliveCalled = true
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
override suspend fun close() {
|
||||
closeCalled = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
194
desktop/proguard-rules.pro
vendored
194
desktop/proguard-rules.pro
vendored
|
|
@ -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.**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
plugins {
|
||||
alias(libs.plugins.meshtastic.kmp.feature)
|
||||
alias(libs.plugins.meshtastic.kotlinx.serialization)
|
||||
id("meshtastic.kmp.jvm.android")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() ?: ""
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue