diff --git a/.copilotignore b/.copilotignore deleted file mode 100644 index 02ec3ad1d..000000000 --- a/.copilotignore +++ /dev/null @@ -1,27 +0,0 @@ -# Ignore build artifacts and generated files from Copilot indexing -# This saves context window tokens and prevents Copilot from hallucinating off of minified code. - -# Build directories -**/build/** -.gradle/ -.idea/ - -# Android generated files -**/generated/** -.cxx/ -.externalNativeBuild/ - -# Git history & worktrees -.git/ -.worktrees/ - -# Protobuf (Prevents Copilot from suggesting raw protobuf byte buffers) -core/proto/ - -# Environment and secrets -local.properties -secrets.properties -*.jks - -# Agent References (Prevents pollution of project space with external code) -.agent_refs/ diff --git a/.gemini/settings.json b/.gemini/settings.json deleted file mode 100644 index 5e535b215..000000000 --- a/.gemini/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "context": { - "fileName": ["AGENTS.md", "GEMINI.md"] - } -} diff --git a/.github/actions/gradle-setup/action.yml b/.github/actions/gradle-setup/action.yml index a42959190..3753210b8 100644 --- a/.github/actions/gradle-setup/action.yml +++ b/.github/actions/gradle-setup/action.yml @@ -27,14 +27,19 @@ runs: distribution: ${{ inputs.jdk_distribution }} token: ${{ github.token }} + # Robolectric downloads instrumented SDK jars from Maven Central at test time. + # Cache them to avoid flaky SocketException failures on CI runners. + # Update the key when bumping robolectric version in libs.versions.toml or sdk in robolectric.properties. + - name: Cache Robolectric SDK jars + uses: actions/cache@v5 + with: + path: ~/.m2/repository/org/robolectric + key: robolectric-4.16.1-sdk34 + - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: cache-read-only: ${{ inputs.cache_read_only }} cache-encryption-key: ${{ inputs.gradle_encryption_key }} cache-cleanup: on-success - add-job-summary: always - gradle-home-cache-includes: | - caches - notifications - ~/.m2/repository/org/robolectric \ No newline at end of file + add-job-summary: always \ No newline at end of file diff --git a/.github/copilot-commit-message-instructions.md b/.github/copilot-commit-message-instructions.md deleted file mode 100644 index 93c242d16..000000000 --- a/.github/copilot-commit-message-instructions.md +++ /dev/null @@ -1,27 +0,0 @@ -# GitHub Copilot Commit Message Instructions - - -You are an expert Git maintainer enforcing Conventional Commits. - - - -1. **Format:** Use the Conventional Commits format: `(): ` (Replace angle brackets with actual text, do NOT output angle brackets). -2. **Types allowed:** - - `feat` (new feature for the user, not a new feature for build script) - - `fix` (bug fix for the user, not a fix to a build script) - - `docs` (changes to the documentation) - - `style` (formatting, missing semi colons, etc; no production code change) - - `refactor` (refactoring production code, e.g. KMP migration, extracting to commonMain) - - `test` (adding missing tests, refactoring tests; no production code change) - - `chore` (updating grunt tasks etc; no production code change) -3. **Scope:** Use the module or logical component as the scope (e.g., `ui`, `navigation`, `ble`, `firmware`, `deps`, `ai`). -4. **Subject line:** - - Use the imperative, present tense: "change" not "changed" nor "changes". - - Do not capitalize the first letter. - - Do not use a period (.) at the end. - - Keep it under 50 characters if possible. -5. **Body (Optional but recommended for large diffs):** - - Leave one blank line after the subject. - - Explain *why* the change was made, not just *what* changed. - - If migrating to KMP or extracting to `commonMain`, explicitly state "Decoupled from Android framework". - diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e856cbe8f..2e60f3dff 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ -# Meshtastic Android - GitHub Copilot Guide +# Meshtastic Android - Agent Guide -> **Note:** The canonical instructions for all AI Agents have been deduplicated. +**Canonical instructions live in [`AGENTS.md`](../AGENTS.md).** This file exists at `.github/copilot-instructions.md` so GitHub Copilot discovers it automatically. -You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. -After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks. +See [AGENTS.md](../AGENTS.md) for architecture, conventions, execution protocol, and coding standards. +See [docs/agent-playbooks/README.md](../docs/agent-playbooks/README.md) for version baselines and task recipes. diff --git a/.github/copilot-pull-request-instructions.md b/.github/copilot-pull-request-instructions.md deleted file mode 100644 index 8e79d63d2..000000000 --- a/.github/copilot-pull-request-instructions.md +++ /dev/null @@ -1,18 +0,0 @@ -# GitHub Copilot Pull Request Instructions - - -You are an expert open-source maintainer. Your goal is to write clear, professional, and highly structured Pull Request descriptions based on the provided diffs. - - - -1. **Remove Boilerplate:** Always delete the "tips" section at the top of the `PULL_REQUEST_TEMPLATE.md` before generating your text. -2. **Context First:** Start with a clear, 1-2 sentence summary of *why* this change is being made. If the branch name or commits reference an issue (e.g., `fix-1234`), explicitly add `Fixes #1234` or `Resolves #1234`. -3. **Structured Changes:** Break down the code changes into bullet points categorized by: - - 🌟 **New Features** (UI, modules, logic) - - 🛠️ **Refactoring & Architecture** (KMP migrations, Koin DI updates) - - 🐛 **Bug Fixes** - - 🧹 **Chores** (Dependencies, formatting, docs) -4. **Architecture Callouts:** If the diff includes moving files from `androidMain` to `commonMain`, or migrating from Android Views to Compose, highlight this as a "KMP Migration Milestone". -5. **Testing Callouts:** If the diff includes changes to `commonTest` or mentions tests, add a section called "Testing Performed" and list the tests that were added/modified. -6. **No "Magic" Text:** Do not invent URLs or insert fake image placeholders. Leave the HTML comment block for images intact so the user can manually add their screenshots. - diff --git a/.github/instructions/android-source-set.instructions.md b/.github/instructions/android-source-set.instructions.md deleted file mode 100644 index 6179bc61a..000000000 --- a/.github/instructions/android-source-set.instructions.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -applyTo: "**/androidMain/**/*.kt" ---- - -# Android Source-Set Rules - -- This is `androidMain` — Android framework imports (`android.*`, `java.*`) are allowed here. -- Do NOT put business logic here. Business logic belongs in `commonMain`. -- If you find identical pure-Kotlin logic in both `androidMain` and `jvmMain`, extract it to `commonMain`. -- Use `expect`/`actual` only for small platform primitives. Prefer interfaces + DI. -- Keep `expect` declarations in `FileIo.kt` and shared helpers in `FileIoUtils.kt` to avoid JVM duplicate class errors. diff --git a/.github/instructions/build-logic.instructions.md b/.github/instructions/build-logic.instructions.md deleted file mode 100644 index d61fa34b8..000000000 --- a/.github/instructions/build-logic.instructions.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -applyTo: "build-logic/**/*.kt" ---- - -# Build-Logic Convention Plugin Rules - -- Prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). -- Avoid `afterEvaluate` unless there is no viable lazy alternative. -- Check `gradle/libs.versions.toml` for version catalog aliases before adding new ones. -- Convention plugins: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`. diff --git a/.github/instructions/ci-workflows.instructions.md b/.github/instructions/ci-workflows.instructions.md deleted file mode 100644 index 55a72b328..000000000 --- a/.github/instructions/ci-workflows.instructions.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -applyTo: "**/*.yml" -excludeAgent: "code-review" ---- - -# CI Workflow Rules - -- Prefer explicit Gradle task paths (`app:lintFdroidDebug`) over shorthand (`lintDebug`). -- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values. -- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`. -- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise. -- Desktop build matrix: `macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`. -- Lightweight jobs (labelers, triage, stale): use `ubuntu-24.04-arm` runners. -- Gradle-heavy jobs: use `ubuntu-24.04` runners. diff --git a/.github/instructions/kmp-common.instructions.md b/.github/instructions/kmp-common.instructions.md deleted file mode 100644 index 7dac915bc..000000000 --- a/.github/instructions/kmp-common.instructions.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -applyTo: "**/commonMain/**/*.kt" ---- - -# KMP commonMain Rules - -- NEVER import `java.*` or `android.*` in `commonMain`. -- Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO`. -- Use Okio (`BufferedSource`/`BufferedSink`) instead of `java.io.*`. -- Use `kotlinx.coroutines.sync.Mutex` instead of `java.util.concurrent.locks.*`. -- Use `atomicfu` or Mutex-guarded `mutableMapOf()` instead of `ConcurrentHashMap`. -- Use `jetbrains-*` catalog aliases for lifecycle/navigation dependencies. -- Use `compose-multiplatform-*` catalog aliases for CMP dependencies. -- Never use plain `androidx.compose` dependencies in `commonMain`. -- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings. -- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`. -- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls. -- Check `gradle/libs.versions.toml` before adding dependencies. -- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code. -- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`. diff --git a/.github/lsp.json b/.github/lsp.json deleted file mode 100644 index 983ecf785..000000000 --- a/.github/lsp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "lspServers": { - "kotlin": { - "command": "kotlin-language-server", - "args": [], - "fileExtensions": { - ".kt": "kotlin", - ".kts": "kotlin" - } - } - } -} diff --git a/.github/renovate.json b/.github/renovate.json index 1faa1a4ad..c9993abac 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -49,31 +49,236 @@ "automerge": true }, { - "description": "Meshtastic Protobufs changelog link", "matchPackageNames": [ "https://github.com/meshtastic/protobufs.git" ], "changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}", + "groupName": "Meshtastic Protobufs", + "groupSlug": "meshtastic-protobufs", "automerge": true }, { - "description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)", - "groupName": "compose-multiplatform", + "description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)", + "groupName": "AndroidX (General)", + "groupSlug": "androidx-general", "matchPackageNames": [ - "/^org\\.jetbrains\\.compose/", - "androidx.compose.runtime:runtime-tracing", - "androidx.compose.ui:ui-test-manifest" + "/^androidx\\./", + "!/^androidx\\.room/", + "!/^androidx\\.lifecycle/", + "!/^androidx\\.navigation/", + "!/^androidx\\.datastore/", + "!/^androidx\\.compose\\.material3\\.adaptive/", + "!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/", + "!/^androidx\\.test\\.espresso/", + "!/^androidx\\.test\\.ext/", + "!/^androidx\\.compose\\.ui:ui-test-junit4$/", + "!/^androidx\\.hilt/" ] }, { - "description": "Restrict sensitive infrastructure to manual minor updates", + "description": "Group Kotlin standard library, coroutines, and serialization", + "groupName": "Kotlin Ecosystem", + "groupSlug": "kotlin", + "matchPackageNames": [ + "/^org\\.jetbrains\\.kotlin/", + "/^org\\.jetbrains\\.kotlinx/" + ] + }, + { + "description": "Group Dagger and Hilt dependencies", + "groupName": "Dagger & Hilt", + "groupSlug": "hilt", + "matchPackageNames": [ + "/^com\\.google\\.dagger/", + "/^androidx\\.hilt/" + ] + }, + { + "description": "Group Accompanist libraries", + "groupName": "Accompanist", + "groupSlug": "accompanist", + "matchPackageNames": [ + "/^com\\.google\\.accompanist/" + ] + }, + { + "description": "Group JVM testing libraries (JUnit, Mockito, Robolectric)", + "groupName": "JVM Testing Libraries", + "groupSlug": "jvm-testing", + "matchPackageNames": [ + "/^junit:junit$/", + "/^org\\.mockito:/", + "/^org\\.robolectric:robolectric$/" + ], + "automerge": true + }, + { + "description": "Group AndroidX Testing libraries", + "groupName": "AndroidX Testing", + "groupSlug": "androidx-testing", + "matchPackageNames": [ + "/^androidx\\.test\\.espresso/", + "/^androidx\\.test\\.ext/", + "/^androidx\\.compose\\.ui:ui-test-junit4$/" + ], + "automerge": true + }, + { + "description": "Group Static Analysis tools (Detekt, Spotless)", + "groupName": "Static Analysis", + "groupSlug": "static-analysis", + "matchPackageNames": [ + "/^io\\.gitlab\\.arturbosch\\.detekt/", + "/^io\\.nlopez\\.compose\\.rules/", + "/^com\\.diffplug\\.spotless/" + ], + "automerge": true + }, + { + "description": "Group Square networking libraries (OkHttp, Retrofit)", + "groupName": "Square Networking", + "groupSlug": "square-network", + "matchPackageNames": [ + "/^com\\.squareup\\.okhttp3/", + "/^com\\.squareup\\.retrofit2/" + ], + "automerge": true + }, + { + "description": "Group Coil image loading library", + "groupName": "Coil", + "groupSlug": "coil", + "matchPackageNames": [ + "/^io\\.coil-kt\\.coil3/" + ], + "automerge": true + }, + { + "description": "Group ZXing barcode scanning libraries", + "groupName": "ZXing", + "groupSlug": "zxing", + "matchPackageNames": [ + "/^com\\.journeyapps:zxing-android-embedded/", + "/^com\\.google\\.zxing:core/" + ], + "automerge": true + }, + { + "description": "Group Eclipse Paho MQTT client libraries", + "groupName": "MQTT Paho Client", + "groupSlug": "mqtt-paho", + "matchPackageNames": [ + "/^org\\.eclipse\\.paho/" + ], + "automerge": true + }, + { + "description": "Group Mike Penz Markdown renderer libraries", + "groupName": "Markdown Renderer (Mike Penz)", + "groupSlug": "markdown-renderer-mikepenz", + "matchPackageNames": [ + "/^com\\.mikepenz/" + ], + "automerge": true + }, + { + "description": "Group Firebase libraries", + "groupName": "Firebase", + "groupSlug": "firebase", + "matchPackageNames": [ + "/^com\\.google\\.firebase/" + ], + "automerge": true + }, + { + "description": "Group Datadog libraries", + "groupName": "Datadog", + "groupSlug": "datadog", + "matchPackageNames": [ + "/^com\\.datadoghq/" + ], + "automerge": true + }, + { + "description": "Group OpenStreetMap (OSM) libraries", + "groupName": "OSM Libraries", + "groupSlug": "osm-libraries", + "matchPackageNames": [ + "/^org\\.osmdroid/", + "/^com\\.github\\.MKergall\\.osmbonuspack/", + "/^mil\\.nga/" + ], + "automerge": true + }, + { + "description": "Group Google Maps Compose libraries", + "groupName": "Google Maps Compose", + "groupSlug": "google-maps-compose", + "matchPackageNames": [ + "/^com\\.google\\.android\\.gms:play-services-location/", + "/^com\\.google\\.maps\\.android/" + ], + "automerge": true + }, + { + "description": "Group Google Protobuf runtime libraries", + "groupName": "Protobuf Runtime", + "groupSlug": "protobuf-runtime", + "matchPackageNames": [ + "/^com\\.google\\.protobuf/", + "!https://github.com/meshtastic/protobufs.git" + ] + }, + { + "description": "Group AndroidX Room libraries", + "groupName": "AndroidX Room", + "groupSlug": "androidx-room", + "matchPackageNames": [ + "/^androidx\\.room/" + ], + "automerge": true + }, + { + "description": "Group AndroidX Lifecycle libraries", + "groupName": "AndroidX Lifecycle", + "groupSlug": "androidx-lifecycle", + "matchPackageNames": [ + "/^androidx\\.lifecycle/" + ] + }, + { + "description": "Group AndroidX Navigation libraries", + "groupName": "AndroidX Navigation", + "groupSlug": "androidx-navigation", + "matchPackageNames": [ + "/^androidx\\.navigation/" + ] + }, + { + "description": "Group AndroidX DataStore libraries", + "groupName": "AndroidX DataStore", + "groupSlug": "androidx-datastore", + "matchPackageNames": [ + "/^androidx\\.datastore/" + ] + }, + { + "description": "Group AndroidX Adaptive UI libraries", + "groupName": "AndroidX Adaptive UI", + "groupSlug": "androidx-adaptive-ui", + "matchPackageNames": [ + "/^androidx\\.compose\\.material3\\.adaptive/", + "/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/" + ] + }, + { + "description": "Restrict sensitive infrastructure to patch updates only (manual minor)", "matchUpdateTypes": [ "minor" ], "matchPackageNames": [ "/^org\\.jetbrains\\.kotlin/", "/^org\\.jetbrains\\.kotlinx/", - "/^org\\.jetbrains\\.compose/", "/^com\\.google\\.dagger/", "/^androidx\\.hilt/", "/^com\\.google\\.protobuf/", @@ -93,4 +298,4 @@ "automerge": false } ] -} +} \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..e67a217c7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,108 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + # push: + # branches: [ "main" ] + # pull_request: + # branches: [ "main" ] + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-24.04' }} + if: github.repository == 'meshtastic/Meshtastic-Android' + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: java-kotlin + build-mode: autobuild + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + - name: Java Setup + uses: actions/setup-java@v5 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '21' + token: ${{ github.token }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f7c8151c7..568da41f4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,16 +6,6 @@ on: push: branches: - main - paths: - # Only rebuild docs when source code changes (Dokka generates from KDoc) - - 'app/src/**' - - 'core/**/src/**' - - 'feature/**/src/**' - - 'desktop/src/**' - - 'build-logic/**' - - 'build.gradle.kts' - - 'settings.gradle.kts' - - '.github/workflows/docs.yml' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -39,11 +29,11 @@ permissions: pages: write id-token: write -# Allow only one concurrent deployment; cancel queued runs since only the latest -# main state matters for documentation. +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" - cancel-in-progress: true + cancel-in-progress: false jobs: build-docs: @@ -66,7 +56,7 @@ jobs: run: ./gradlew dokkaGeneratePublicationHtml - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@v4 with: path: build/dokka/html diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml index eaf3f54d3..4c29847a3 100644 --- a/.github/workflows/main-check.yml +++ b/.github/workflows/main-check.yml @@ -20,7 +20,8 @@ jobs: uses: ./.github/workflows/reusable-check.yml with: run_lint: true - run_unit_tests: false - run_desktop_builds: false + run_unit_tests: true + run_instrumented_tests: true + api_levels: '[35]' # One API level is enough for post-merge sanity check upload_artifacts: true secrets: inherit diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml index 44d31183d..2818ca939 100644 --- a/.github/workflows/merge-queue.yml +++ b/.github/workflows/merge-queue.yml @@ -18,6 +18,8 @@ jobs: with: run_lint: true run_unit_tests: true + run_instrumented_tests: true + api_levels: '[26, 35]' # Comprehensive testing for Merge Queue upload_artifacts: false secrets: inherit diff --git a/.github/workflows/models_pr_triage.yml b/.github/workflows/models_pr_triage.yml index c2a1aaf25..2cfe6b15e 100644 --- a/.github/workflows/models_pr_triage.yml +++ b/.github/workflows/models_pr_triage.yml @@ -44,16 +44,13 @@ 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: ${{ env.PR_TITLE }} - Body: ${{ env.PR_BODY }} + Title: ${{ github.event.pull_request.title }} + Body: ${{ github.event.pull_request.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. @@ -97,9 +94,6 @@ 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: | @@ -111,8 +105,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: ${{ env.PR_TITLE }} - Body: ${{ env.PR_BODY }} + Title: ${{ github.event.pull_request.title }} + Body: ${{ github.event.pull_request.body }} system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label. model: openai/gpt-4o-mini diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index df16866f3..2338a6aeb 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -139,7 +139,6 @@ jobs: gh release edit ${{ inputs.tag_name }} \ --tag ${{ inputs.final_tag }} \ --title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \ - --draft=false \ --prerelease=${{ inputs.channel != 'production' }} - name: Notify Discord diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d450711ce..6649dbc84 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -3,6 +3,10 @@ name: Pull Request CI on: pull_request: branches: [ main ] + paths-ignore: + - '**/*.md' + - 'docs/**' + - '.gitignore' permissions: contents: read @@ -35,6 +39,7 @@ jobs: - 'desktop/**' - 'core/**' - 'feature/**' + - 'mesh_service_example/**' # Shared build infrastructure - 'build-logic/**' - 'config/**' @@ -94,9 +99,7 @@ jobs: PY # 2. VALIDATION & BUILD: Delegate to reusable-check.yml - # We disable coverage and desktop builds for PRs to keep feedback fast - # (< 10 mins). Desktop compilation is already covered by the :desktop:test - # task in the shard-app test shard. + # We disable instrumented tests and coverage for PRs to keep feedback fast (< 10 mins). validate-and-build: needs: check-changes if: needs.check-changes.outputs.android == 'true' @@ -104,8 +107,9 @@ jobs: with: run_lint: true run_unit_tests: true + run_instrumented_tests: false run_coverage: false - run_desktop_builds: false + api_levels: '[35]' upload_artifacts: true secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40d8e40f3..77687a105 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -328,7 +328,7 @@ jobs: path: ./artifacts - name: Create or Update GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ inputs.tag_name }} target_commitish: ${{ inputs.commit_sha || github.sha }} @@ -341,7 +341,7 @@ jobs: - name: Create or Update internal GitHub Release continue-on-error: true if: ${{ env.INTERNAL_BUILDS_HOST != '' }} - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 with: repository: ${{ secrets.INTERNAL_BUILDS_HOST }} token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }} diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 632bf1ea4..75557fe00 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -9,12 +9,15 @@ on: run_unit_tests: type: boolean default: true + run_instrumented_tests: + type: boolean + default: true run_coverage: type: boolean default: true - run_desktop_builds: - type: boolean - default: true + api_levels: + type: string + default: '[35]' upload_artifacts: type: boolean default: true @@ -94,7 +97,7 @@ jobs: - name: Lint, Analysis & KMP Smoke Compile if: inputs.run_lint == true - run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan + run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug kmpSmokeCompile -Pci=true --continue --scan - name: KMP Smoke Compile (lint skipped) if: inputs.run_lint == false @@ -173,12 +176,14 @@ jobs: :desktop:test :core:barcode:testFdroidDebugUnitTest :core:barcode:testGoogleDebugUnitTest + :mesh_service_example:test kover: >- :app:koverXmlReportFdroidDebug :app:koverXmlReportGoogleDebug :core:barcode:koverXmlReportFdroidDebug :core:barcode:koverXmlReportGoogleDebug :desktop:koverXmlReport + :mesh_service_example:koverXmlReportDebug steps: - name: Checkout code @@ -213,7 +218,7 @@ jobs: files: "**/build/test-results/**/*.xml" - name: Upload coverage to Codecov - if: ${{ !cancelled() && inputs.run_coverage }} + if: ${{ !cancelled() }} uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -232,8 +237,130 @@ jobs: **/build/test-results retention-days: 7 - # ── Android Build ──────────────────────────────────────────────────── + # ── Android Build & Instrumented Tests ────────────────────────────── android-check: + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 60 + needs: lint-check + env: + VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} + strategy: + fail-fast: true + matrix: + api_level: ${{ fromJson(inputs.api_levels) }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + submodules: true + + - name: Gradle Setup + uses: ./.github/actions/gradle-setup + with: + gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} + + - name: Determine matrix metadata + id: matrix_meta + shell: bash + run: | + first_api=$(python3 - <<'PY' + import json + print(json.loads('${{ inputs.api_levels }}')[0]) + PY + ) + + if [[ "${{ matrix.api_level }}" == "$first_api" ]]; then + echo "is_first_api=true" >> "$GITHUB_OUTPUT" + else + echo "is_first_api=false" >> "$GITHUB_OUTPUT" + fi + + - name: Determine Android tasks + id: tasks + shell: bash + run: | + tasks=( + "app:assembleFdroidDebug" + "app:assembleGoogleDebug" + "mesh_service_example:assembleDebug" + ) + + if [[ "${{ inputs.run_instrumented_tests }}" == "true" ]]; then + tasks+=( + "app:connectedFdroidDebugAndroidTest" + "app:connectedGoogleDebugAndroidTest" + ) + fi + + printf 'tasks=%s\n' "${tasks[*]}" >> "$GITHUB_OUTPUT" + + - name: Enable KVM group perms + if: inputs.run_instrumented_tests == true + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run Android Build & Instrumented Tests + if: inputs.run_instrumented_tests == true + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api_level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan + + - name: Run Android Build + if: inputs.run_instrumented_tests == false + run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan + + - name: Upload instrumented test results to Codecov + if: ${{ !cancelled() && inputs.run_instrumented_tests && steps.matrix_meta.outputs.is_first_api == 'true' }} + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: meshtastic/Meshtastic-Android + flags: android-instrumented + fail_ci_if_error: false + report_type: test_results + files: "**/build/outputs/androidTest-results/**/*.xml" + + - name: Upload debug artifact + if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }} + uses: actions/upload-artifact@v7 + with: + name: app-debug-apks + path: app/build/outputs/apk/*/debug/*.apk + retention-days: 14 + + - name: Report App Size + if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }} + run: | + echo "### App Size Report" >> $GITHUB_STEP_SUMMARY + echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY + echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY + find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY + + - name: Upload Android reports + if: ${{ always() && inputs.upload_artifacts }} + uses: actions/upload-artifact@v7 + with: + name: reports-android-api-${{ matrix.api_level }} + path: | + **/build/outputs/androidTest-results + retention-days: 7 + if-no-files-found: ignore + + # ── Desktop Build ─────────────────────────────────────────────────── + build-desktop: + name: Build Desktop Debug runs-on: ubuntu-24.04 permissions: contents: read @@ -242,54 +369,6 @@ jobs: env: VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - submodules: true - - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - - - name: Build Android APKs - run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan - - - name: Upload debug artifact - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 - with: - name: app-debug-apks - path: app/build/outputs/apk/*/debug/*.apk - retention-days: 7 - - - name: Report App Size - if: always() - run: | - echo "### App Size Report" >> $GITHUB_STEP_SUMMARY - echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY - echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY - find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY - - # ── Desktop Build ─────────────────────────────────────────────────── - build-desktop: - name: Build Desktop Debug (${{ matrix.os }}) - if: inputs.run_desktop_builds == true - runs-on: ${{ matrix.os }} - permissions: - contents: read - timeout-minutes: 60 - needs: lint-check - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm] - env: - VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} - steps: - name: Checkout code uses: actions/checkout@v6 @@ -304,12 +383,12 @@ jobs: cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - name: Build Desktop - run: ./gradlew :desktop:createDistributable -Pci=true --scan + run: ./gradlew :desktop:packageDistributionForCurrentOS -Pci=true --scan - name: Upload Desktop artifact if: ${{ inputs.upload_artifacts }} uses: actions/upload-artifact@v7 with: - name: desktop-app-${{ runner.os }}-${{ runner.arch }} - path: desktop/build/compose/binaries/main/app/ + name: desktop-app + path: desktop/build/compose/binaries/main/app/Meshtastic/bin/* retention-days: 7 diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml index 2399d1f88..d516537e0 100644 --- a/.github/workflows/scheduled-updates.yml +++ b/.github/workflows/scheduled-updates.yml @@ -2,8 +2,8 @@ name: Scheduled Updates (Firmware, Hardware, Translations) on: schedule: - - cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost) - workflow_dispatch: # Allow manual triggering + - cron: '0 * * * *' # Run every hour + workflow_dispatch: # Allow manual triggering jobs: update_assets: diff --git a/.gitignore b/.gitignore index 447d8a28e..97dbb7b24 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,3 @@ wireless-install.sh .worktrees/ /firebase-debug.log.jdk/ firebase-debug.log -.agent_plans/ -.agent_refs/ -.agent_artifacts/ diff --git a/.pr5167.diff b/.pr5167.diff deleted file mode 100644 index d0a809449..000000000 --- a/.pr5167.diff +++ /dev/null @@ -1,295 +0,0 @@ -diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt -new file mode 100644 -index 0000000000..2a27b96906 ---- /dev/null -+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt -@@ -0,0 +1,39 @@ -+/* -+ * Copyright (c) 2026 Meshtastic LLC -+ * -+ * This program is free software: you can redistribute it and/or modify -+ * it under the terms of the GNU General Public License as published by -+ * the Free Software Foundation, either version 3 of the License, or -+ * (at your option) any later version. -+ * -+ * This program is distributed in the hope that it will be useful, -+ * but WITHOUT ANY WARRANTY; without even the implied warranty of -+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+ * GNU General Public License for more details. -+ * -+ * You should have received a copy of the GNU General Public License -+ * along with this program. If not, see . -+ */ -+package org.meshtastic.core.common.di -+ -+import kotlinx.coroutines.CoroutineScope -+import kotlinx.coroutines.SupervisorJob -+import org.koin.core.annotation.Single -+import org.meshtastic.core.common.util.ioDispatcher -+ -+/** -+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. -+ * -+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled -+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not -+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread. -+ * -+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch -+ * and should be used sparingly. -+ */ -+interface ApplicationCoroutineScope : CoroutineScope -+ -+@Single(binds = [ApplicationCoroutineScope::class]) -+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { -+ override val coroutineContext = SupervisorJob() + ioDispatcher -+} -diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -index 231c84d401..5365ab95e2 100644 ---- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect - import co.touchlab.kermit.Logger - import com.eygraber.uri.toAndroidUri - import com.eygraber.uri.toKmpUri --import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.withContext - import org.jetbrains.compose.resources.StringResource - import org.jetbrains.compose.resources.getString - import org.meshtastic.core.common.gpsDisabled - import org.meshtastic.core.common.util.CommonUri -+import org.meshtastic.core.common.util.ioDispatcher - import java.net.URLEncoder - - @Composable -@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> - val context = LocalContext.current - return remember(context) { - { uri, maxChars -> -- withContext(Dispatchers.IO) { -+ withContext(ioDispatcher) { - @Suppress("TooGenericExceptionCaught") - try { - val androidUri = uri.toAndroidUri() -diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -index 031e1fe35d..a938f92ea6 100644 ---- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util - - import androidx.compose.runtime.Composable - import co.touchlab.kermit.Logger --import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.withContext - import org.jetbrains.compose.resources.StringResource - import org.meshtastic.core.common.util.CommonUri -+import org.meshtastic.core.common.util.ioDispatcher - import java.awt.Desktop - import java.awt.FileDialog - import java.awt.Frame -@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT - /** JVM — Reads text from a file URI. */ - @Composable - actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> -- withContext(Dispatchers.IO) { -+ withContext(ioDispatcher) { - @Suppress("TooGenericExceptionCaught") - try { - val file = File(URI(uri.toString())) -diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt -index dc1c459716..f8ff9fcac8 100644 ---- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt -+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt -@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch - import kotlinx.coroutines.withTimeoutOrNull - import org.jetbrains.compose.resources.StringResource - import org.koin.core.annotation.KoinViewModel -+import org.meshtastic.core.common.di.ApplicationCoroutineScope - import org.meshtastic.core.common.util.CommonUri - import org.meshtastic.core.common.util.safeCatching - import org.meshtastic.core.database.entity.FirmwareRelease -@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel( - private val firmwareUpdateManager: FirmwareUpdateManager, - private val usbManager: FirmwareUsbManager, - private val fileHandler: FirmwareFileHandler, -+ private val applicationScope: ApplicationCoroutineScope, - ) : ViewModel() { - - private val _state = MutableStateFlow(FirmwareUpdateState.Idle) -@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel( - - override fun onCleared() { - super.onCleared() -- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a -- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a -- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope -- // is cancelled concurrently. -- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) -- kotlinx.coroutines.GlobalScope.launch(NonCancellable) { -+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the -+ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup -+ // running even if something tries to cancel it mid-flight. -+ applicationScope.launch(NonCancellable) { - tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) - } - } -diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt -index 4c48a1ced5..030d84effd 100644 ---- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt -+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt -@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { - firmwareUpdateManager, - usbManager, - fileHandler, -+ TestApplicationCoroutineScope(testDispatcher), - ) - - @Test -diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt -index 7032ed4088..a8eddff838 100644 ---- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt -+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt -@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { - firmwareUpdateManager, - usbManager, - fileHandler, -+ TestApplicationCoroutineScope(testDispatcher), - ) - - @Test -diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt -new file mode 100644 -index 0000000000..3ef5c44ef4 ---- /dev/null -+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt -@@ -0,0 +1,26 @@ -+/* -+ * Copyright (c) 2026 Meshtastic LLC -+ * -+ * This program is free software: you can redistribute it and/or modify -+ * it under the terms of the GNU General Public License as published by -+ * the Free Software Foundation, either version 3 of the License, or -+ * (at your option) any later version. -+ * -+ * This program is distributed in the hope that it will be useful, -+ * but WITHOUT ANY WARRANTY; without even the implied warranty of -+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+ * GNU General Public License for more details. -+ * -+ * You should have received a copy of the GNU General Public License -+ * along with this program. If not, see . -+ */ -+package org.meshtastic.feature.firmware -+ -+import kotlinx.coroutines.CoroutineDispatcher -+import kotlinx.coroutines.CoroutineScope -+import kotlinx.coroutines.SupervisorJob -+import org.meshtastic.core.common.di.ApplicationCoroutineScope -+ -+internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : -+ ApplicationCoroutineScope, -+ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) -diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt -index acb1545bdd..23a0d03ab2 100644 ---- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt -+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt -@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { - firmwareUpdateManager, - usbManager, - fileHandler, -+ TestApplicationCoroutineScope(testDispatcher), - ) - - // ----------------------------------------------------------------------- -diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -index c251b4d5ef..315ad1da85 100644 ---- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger - import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.launch - import kotlinx.coroutines.withContext -+import org.meshtastic.core.common.util.ioDispatcher - import org.meshtastic.core.resources.Res - import org.meshtastic.core.resources.debug_export_failed - import org.meshtastic.core.resources.debug_export_success -@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = -- withContext(Dispatchers.IO) { -+ withContext(ioDispatcher) { - try { - if (logs.isEmpty()) { - withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } -diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt -index 9afde85e5f..a28a576788 100644 ---- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt -+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt -@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable - import androidx.compose.runtime.rememberCoroutineScope - import androidx.compose.ui.platform.LocalContext - import co.touchlab.kermit.Logger --import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.launch - import kotlinx.coroutines.withContext -+import org.meshtastic.core.common.util.ioDispatcher - - @Composable - actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { -@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr - return { fileName -> exportLauncher.launch(fileName) } - } - --private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { -+private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { - try { - context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } - Logger.i { "TAK data package exported successfully to $targetUri" } -diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -index 5b63cc90a3..a9a7285593 100644 ---- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging - import androidx.compose.runtime.Composable - import androidx.compose.runtime.rememberCoroutineScope - import co.touchlab.kermit.Logger --import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.launch - import kotlinx.coroutines.withContext -+import org.meshtastic.core.common.util.ioDispatcher - import java.awt.FileDialog - import java.awt.Frame - import java.io.File -@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr - if (directory != null && file != null) { - val targetFile = File(directory, file) - val data = dataPackageProvider() -- withContext(Dispatchers.IO) { targetFile.writeBytes(data) } -+ withContext(ioDispatcher) { targetFile.writeBytes(data) } - Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } - } - } diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md deleted file mode 100644 index acab253d5..000000000 --- a/.skills/code-review/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ -# Skill: Code Review - -## Description -Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices. - -## Code Review Checklist - -When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix. - -### 1. KMP Architecture & Source Set Boundaries -- [ ] **No Platform Bleed:** Ensure absolutely no `java.*` or `android.*` imports exist in `commonMain` source sets. -- [ ] **KMP Native Alternatives:** Verify the use of KMP alternatives for standard JVM libraries: - - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex` - - `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()` - - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`) - - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`) -- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`). -- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`. -- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target. -- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities. - -### 2. UI & Compose Multiplatform (CMP) -- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine. -- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI). -- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`. -- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules. -- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp). - -### 3. Navigation & State -- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(...)`). Flag any graphs defined in platform-specific source sets. -- [ ] **Navigation Host:** Ensure `MeshtasticNavDisplay` (from `core:ui/commonMain`) is used as the host instead of invoking `NavDisplay` directly. Host modules should not configure `entryDecorators` themselves. -- [ ] **ViewModel Scoping:** ViewModels obtained via `koinViewModel()` must be inside `entry` blocks to correctly tie to the backstack lifetime. - -### 4. Dependency Injection (Koin Annotations) -- [ ] **Annotation Usage:** Ensure Koin is configured via annotations (`@Single`, `@Factory`, `@KoinViewModel`). -- [ ] **Root Assembly:** Confirm that the root Koin DI graph is only assembled in host shells (`app` and `desktop`). - -### 5. Networking, DB & I/O -- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp. -- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths. -- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers. -- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`. -- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead. -- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions. - -### 6. Dependency Catalog Aliases -- [ ] **JetBrains vs. AndroidX:** - - In `commonMain`: Must use `jetbrains-*` aliases (e.g., `jetbrains-lifecycle-*`, `jetbrains-navigation3-ui`). - - In `androidMain`: Can use `androidx-*` or `jetbrains-*` as appropriate, but do not mix them up in `commonMain`. -- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules. - -### 7. Testing -- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` from `androidx.compose.ui.test.v2` (not the deprecated v1 `androidx.compose.ui.test` package) + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests. -- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`. -- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. -- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues. - -### 8. ProGuard / R8 Rules -- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned. -- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed. - -## Review Output Guidelines -1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern. -2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio"). -3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous. -4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions. diff --git a/.skills/compose-ui/SKILL.md b/.skills/compose-ui/SKILL.md deleted file mode 100644 index 22fe1b489..000000000 --- a/.skills/compose-ui/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ -# Skill: Compose Multiplatform (CMP) UI - -## Description -Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive. - -## 1. UI Components & Layouts -- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets. -- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups. -- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. -- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`. -- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`. - -## 2. Strings & Resources -- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings. -- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context. -- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer). - - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`): - ```kotlin - val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5" - stringResource(Res.string.battery_percent, formatted) // uses %1$s - ``` - - **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings. - -### String Formatting Decision Tree -Choose the right tool for the job: - -| Scenario | Tool | Example | -|----------|------|---------| -| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)` → `"77.0°F"` | -| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` | -| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` | -| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` | -| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` | -| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` | - -**Rules:** -1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats. -2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls. -3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`. -4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision. - -- **Workflow to Add a String:** - 1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`. - 2. Use the generated `org.meshtastic.core.resources.` symbol. - 3. Validate UI presentation. - -## 3. Tooling & Capabilities -- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules. -- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code. - -## 4. Compose Previews -- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables. -- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated. - -## 5. Dialog & State Patterns -- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed. - -## Reference Anchors -- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml` -- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` -- **Provider wiring:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` diff --git a/.skills/implement-feature/SKILL.md b/.skills/implement-feature/SKILL.md deleted file mode 100644 index 0277bee10..000000000 --- a/.skills/implement-feature/SKILL.md +++ /dev/null @@ -1,41 +0,0 @@ -# Skill: Implement a Feature - -## Description -A step-by-step workflow for implementing a new feature in the Meshtastic-Android codebase, ensuring KMP compatibility and proper architecture. - -## Workflow - -### 1. Update Dependencies & Aliases -- Check `gradle/libs.versions.toml` before adding libraries. -- Use `jetbrains-*` aliases for lifecycle/navigation/adaptive dependencies in `commonMain`. -- Use `compose-multiplatform-*` aliases for CMP dependencies. - -### 2. Define the State & ViewModels -- Follow MVI/UDF patterns. -- Extend shared ViewModel logic in `feature//src/commonMain/kotlin/org/meshtastic/feature//ViewModel.kt`. -- Use `stateInWhileSubscribed` (from `core:ui`) for sharing state flows. -- Keep the ViewModel free of Android framework dependencies. - -### 3. Build the UI -- Use Jetpack Compose Multiplatform (CMP). -- Define strings in `core:resources` (see the `compose-ui` skill). -- Support adaptive layouts (Large/XL breakpoints). - -### 4. Wire Navigation & DI -- Define typed route objects in `core:navigation`. -- Export the navigation graph as an extension function on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.myFeatureGraph()`). -- Add the required DI bindings via Koin Annotations (`@Factory`, `@Single`, `@KoinViewModel`) in `commonMain`. -- **CRITICAL:** Ensure the module is registered in the app root graphs (`AppKoinModule.kt`, `DesktopKoinModule.kt`) and the navigation is injected into the root entry provider in the host shell. - -### 5. Validate Platform Separation -- If you need a platform-specific API (like camera or specific mapping SDK), define an interface in `commonMain`, implement it in the host shell, and inject it via `CompositionLocal` or Koin. - -### 6. Verify Locally -- Run the baseline checks (see `testing-ci` skill): - ```bash - ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests - ``` -- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds: - ```bash - ./gradlew assembleFdroidRelease :desktop:runRelease - ``` diff --git a/.skills/kmp-architecture/SKILL.md b/.skills/kmp-architecture/SKILL.md deleted file mode 100644 index 46602c430..000000000 --- a/.skills/kmp-architecture/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ -# Skill: KMP Architecture & Source-Set Bridging - -## Description -Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstractions, networking, database, and platform integration rules. - -## 1. Source-Set Boundaries -- **`commonMain`:** All business logic, DB entities, API network logic, ViewModels, and UI rendering. NO `java.*` or `android.*` imports. -- **`androidMain`:** Android framework integration (`Context`, system services, NFC hardware, BLE Android bindings). -- **`jvmMain` / `jvmAndroidMain`:** Shared JVM code between Android and Desktop. Uses the `meshtastic.kmp.jvm.android` convention plugin to bridge `jvm` and `android` source sets without manual `dependsOn` hacks. -- **`app` / `desktop`:** Host shells. Responsible for Koin DI root wiring, `MainKoinModule`, host-level UI themes, and running the `MeshtasticNavDisplay`. - -## 2. Bridging Strategies -- **Interface + DI (Preferred):** Expose an interface in `core:repository` or `core:ui` (e.g. `LocationRepository`, `MapViewProvider`), implement it in `androidMain` or the host `app`, and bind it via Koin or `CompositionLocal`. -- **`expect`/`actual` (Restricted):** Use only when a platform API cannot be abstracted cleanly (e.g. low-level File I/O mappings, `uppercase()` Locale helpers). Avoid deep class hierarchies using `expect`/`actual`. - - **Naming:** Keep `expect` in `FileIo.kt`, but put shared helpers in `FileIoUtils.kt` to prevent JVM duplicate class errors. -- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper. - -## 3. Core Libraries & Constraints -- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic. -- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops). -- **Standard Library Replacements:** - - `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`. - - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`. - - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`). -- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging. -- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL. -- **BLE:** Route through `core:ble` using **Kable**. -- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`. - -## 4. Hierarchy & Source-Set Conventions -- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model. -- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient. -- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract to `commonMain`. Examples: `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BaseRadioTransportFactory`. - -## 5. Dependency Catalog Aliases -- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. -- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in `commonMain`. -- **Dependencies:** Always check `gradle/libs.versions.toml` before assuming a library is available. - -## 6. I/O & Serialization -- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision. -- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. -- **Room Patterns:** - - Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic. - - Use `LIMIT 1` on `@Query` methods that expect a single row. - - Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit). - -## 7. Build-Logic Conventions -- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. - -## 8. Onboarding a New Target (Desktop/iOS) -1. Ensure all new logic compiles against the KMP core (`jvm()`, `iosArm64()`, etc.). -2. Do not use platform-specific constructs in `commonMain` or you break the iOS/Desktop builds. -3. Test using `kmpSmokeCompile` to verify cross-platform compilation. -4. For desktop wiring, copy the pattern in `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations. - -## Reference Anchors -- **Shared Okio I/O:** `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt` -- **Desktop DI Stubs:** `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt` -- **Version Catalog:** `gradle/libs.versions.toml` -- **Convention Plugins:** `build-logic/convention/` diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md deleted file mode 100644 index c9d7336a6..000000000 --- a/.skills/navigation-and-di/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ -# Skill: DI and Navigation 3 Architecture - -## Description -This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Navigation 3 (1.1.x) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase. - -## Dependency Injection (Koin) - -### Guidelines -1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature. -2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`. -3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead. -4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs. - -### Anti-Patterns -- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead. -- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values. - -### Koin Startup Pattern (K2 Compiler Plugin) -The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin()` stub, which the plugin transforms at compile time via IR: -```kotlin -// Bootstrap class — separate from @Module, references the root module graph -@KoinApplication(modules = [AppKoinModule::class]) -object AndroidKoinApp - -// In Application.onCreate() -startKoin { - androidContext(this@MeshUtilApplication) - workManagerFactory() -} -``` -- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class. -- `startKoin()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`. -- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`). -- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag. - -## Navigation 3 - -### Guidelines -1. **Types:** Use Navigation 3 types consistently (`NavKey`, `NavBackStack`, `EntryProviderScope`). -2. **Typed Routes:** Keep route definitions in `core:navigation/src/commonMain/.../Routes.kt` as `@Serializable sealed interface` hierarchies. Don't use ad-hoc strings. -3. **Graph Assembly:** Define feature navigation graphs as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(backStack)`). -4. **Host Integration:** Use `MeshtasticNavDisplay` (from `core:ui/commonMain`) as the Navigation 3 host. Do not configure decorators manually inside feature modules. -5. **Back Handlers:** Use `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures in multiplatform code. Do not use Android's `BackHandler`. -6. **Deep Links:** Use `DeepLinkRouter.route()` in `core:navigation` to synthesize typed backstacks from RESTful paths. - -### Anti-Patterns -- **Single Backstack for Multiple Tabs:** Do **not** use a single `NavBackStack` list for multiple tabs. Use `MultiBackstack` (from `core:navigation`). -- **Decorator Reuse Across Tabs:** Do **not** reuse the same `NavEntryDecorator` instances across different backstacks. When rendering an active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance to prevent permanent `ViewModelStore` destruction. -- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack` directly with `add(...)` and `removeLastOrNull()`. - -## Reference Anchors -- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` -- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt` -- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` -- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` -- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` diff --git a/.skills/new-branch/SKILL.md b/.skills/new-branch/SKILL.md deleted file mode 100644 index d63f3f4c2..000000000 --- a/.skills/new-branch/SKILL.md +++ /dev/null @@ -1,79 +0,0 @@ -# Skill: New Branch Bootstrap - -## Description -Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill -whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh -branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work. - -This replaces the ad-hoc prose that used to be retyped at the start of every session. - -## When to Use -- Starting any new feature, fix, chore, or refactor. -- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)). -- Reproducing a CI failure from a clean baseline. - -## Preconditions (verify before branching) -1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding. -2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at - `meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream. -3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md - workspace bootstrap rules. -4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties` - (required for `google` flavor builds). - -## Standard Recipe - -```bash -# 1. Fetch latest upstream -git fetch upstream --prune --tags - -# 2. Create the branch from upstream/main (never from a local stale main) -git switch -c upstream/main - -# 3. Ensure submodules track the new base -git submodule update --init --recursive - -# 4. Sanity check -git --no-pager log -1 --oneline -``` - -## Branch Naming -Use conventional-commit style prefixes that match the PR title convention in AGENTS.md -``: - -| Prefix | Use for | -| :--- | :--- | -| `feat/` | New user-visible behavior | -| `fix/` | Bug fixes | -| `refactor/` | Code structure changes, no behavior change | -| `chore/` | Tooling, deps, CI, cleanup | -| `docs/` | Documentation only | - -Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`. - -## Rebase Variant -When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*: - -```bash -git fetch upstream --prune -gh pr checkout # checks out the PR head locally -git rebase upstream/main -git submodule update --init --recursive -# Resolve conflicts, then: -git push --force-with-lease -``` - -Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes. - -## Post-Branch Checklist -- [ ] Branch name follows conventional prefix. -- [ ] Submodules up to date. -- [ ] `local.properties` exists. -- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap). -- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing. - -## Tip: Prefer `/delegate` for Long Audits -If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since -v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider -suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR -end-to-end while the user keeps working locally. See AGENTS.md ``. diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md deleted file mode 100644 index 2224fa7ad..000000000 --- a/.skills/project-overview/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ -# Skill: Project Overview & Codebase Map - -## Description -Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android. - -- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26. -- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics) -- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`. - -## Codebase Map - -| Directory | Description | -| :--- | :--- | -| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. | -| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). | -| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). | -| `docs/` | Architecture docs and agent playbooks. See `docs/kmp-status.md` and `docs/roadmap.md` for current status. | -| `core/model` | Domain models and common data structures. | -| `core:proto` | Protobuf definitions (Git submodule). | -| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. | -| `core:database` | Room KMP database implementation. | -| `core:datastore` | Multiplatform DataStore for preferences. | -| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). | -| `core:domain` | Pure KMP business logic and UseCases. | -| `core:data` | Core manager implementations and data orchestration. | -| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). | -| `core:di` | Common DI qualifiers and dispatchers. | -| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies. `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence. | -| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | -| `core:service` | KMP service layer; Android bindings stay in `androidMain`. | -| `core:api` | Public AIDL/API integration module for external clients. | -| `core:prefs` | KMP preferences layer built on DataStore abstractions. | -| `core:barcode` | Barcode scanning (Android-only). | -| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. | -| `core/ble/` | Bluetooth Low Energy stack using Kable. | -| `core/resources/` | Centralized string and image resources (Compose Multiplatform). | -| `core/testing/` | Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules. | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP except `widget`. Use `meshtastic.kmp.feature` convention plugin. | -| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. | -| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | -| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. | - -## Namespacing -- **Standard:** Use the `org.meshtastic.*` namespace for all code. -- **Legacy:** Maintain the `com.geeksville.mesh` Application ID. - -## Environment Setup -1. **JDK 21 MUST be used** to prevent Gradle sync/build failures. -2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`: - ```properties - MAPS_API_KEY=dummy_key - datadogApplicationId=dummy_id - datadogClientToken=dummy_token - ``` - -## Workspace Bootstrap (MUST run before any build) -Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you. - -1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it: - ```bash - # Check common macOS/Linux locations in order of preference - if [ -z "$ANDROID_HOME" ]; then - for dir in "$HOME/Library/Android/sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do - if [ -d "$dir" ]; then export ANDROID_HOME="$dir"; break; fi - done - fi - ``` - All `./gradlew` invocations must include `ANDROID_HOME` in the environment. If the SDK cannot be found, ask the user for the path. - -2. **Proto submodule:** `core/proto/src/main/proto` is a Git submodule containing Protobuf definitions. It must be initialized or builds will fail with proto generation errors: - ```bash - git submodule update --init - ``` - -3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails: - ```bash - [ -f local.properties ] || cp secrets.defaults.properties local.properties - ``` - -## Troubleshooting -- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts. -- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist. -- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`. diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md deleted file mode 100644 index 1c8b7b901..000000000 --- a/.skills/testing-ci/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ -# Skill: Testing and CI Verification - -## Description -Guidelines and commands for verifying code changes locally and understanding the Meshtastic-Android CI pipeline. Use this to determine which testing matrix is needed based on the change type. - -## 1) Baseline local verification order - -Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation: - -```bash -./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests -``` - -> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues. - -> **Why `test allTests` and not just `test`:** -> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and -> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules. -> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin. -> Conversely, `allTests` does **not** cover pure-Android modules (`:app`, `:core:api`, etc.), which is why both `test` and `allTests` are needed. - -*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.* - -## 2) Change-type verification matrix - -- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical. -- `UI text/resource` changes: `spotlessCheck`, `detekt`, `assembleDebug`. -- `feature/commonMain logic` changes: `spotlessCheck`, `detekt`, `test allTests`, `assembleDebug`. -- `navigation/DI wiring` changes: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`, plus flavor unit tests if available. - - If touching any KMP module, also run `kmpSmokeCompile`. -- `worker/service/background` changes: Broad tests, targeted WorkManager checks. -- `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`. - -## 3) Flavor checks - -Run these when relevant to map, provider, or flavor-specific behavior: - -```bash -./gradlew lintFdroidDebug lintGoogleDebug -./gradlew testFdroidDebug testGoogleDebug -``` - -## 4) CI Pipeline Architecture - -CI is defined in `.github/workflows/reusable-check.yml` and structured as four parallel job groups: - -1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs. -2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`): - - `shard-core`: `allTests` for all `core:*` KMP modules. - - `shard-feature`: `allTests` for all `feature:*` KMP modules. - - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`). - Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. - Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. -3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`). -4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`). - -### Runner Strategy (Three Tiers) -- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times. -- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, publish, dependency-submission). Pin for reproducibility. -- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging. - -### CI Gradle Properties -`gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides: -- `org.gradle.daemon=false` (single-use runners) -- `kotlin.incremental=false` (fresh checkouts) -- `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon -- VFS watching disabled, workers capped at 4 -- `org.gradle.isolated-projects=true` for better parallelism -- Disables unused Android build features (`resvalues`, `shaders`) - -### CI Conventions -- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks. -- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`. -- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations. -- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{version}-sdk{level}` — update when bumping version or SDK level. -- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing. -- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`). -- **`test-retry` plugin:** Applied to all module types (maxRetries=2, maxFailures=10). -- **`fail-fast: false`:** Test sharding does not cancel other shards on failure. -- **Explicit Gradle task paths:** Prefer `app:lintFdroidDebug` over shorthand `lintDebug` in CI. -- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`). -- **Cache writes:** Trusted on `main` and merge queue runs; other refs use read-only cache. -- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.). -- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone. - diff --git a/AGENTS.md b/AGENTS.md index c1bafdd96..ed603d08a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,108 +1,208 @@ -# Meshtastic Android - Unified Agent & Developer Guide +# Meshtastic Android - Agent Guide - -You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Meshtastic-Android, a decentralized mesh networking application. You must maintain strict architectural boundaries, use Modern Android Development (MAD) standards, and adhere to Compose Multiplatform and JetBrains Navigation 3 patterns. - +This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project. - -- **Project Goal:** Decouple business logic from the Android framework for seamless multi-platform execution (Android, Desktop, iOS) while maintaining a high-performance native Android experience. -- **Language & Tech:** Kotlin 2.3+ (JDK 21 REQUIRED), Gradle Kotlin DSL, Ktor, Okio, Room KMP. -- **Core Architecture:** - - `commonMain` is pure KMP. `androidMain` is strictly for Android framework bindings. - - App root DI and graph assembly live in the `app` and `desktop` host shells. -- **Skills Directory:** You **MUST** consult the relevant `.skills/` module before executing work: - - `.skills/project-overview/` - Codebase map, module directory, namespacing, environment setup, troubleshooting. - - `.skills/kmp-architecture/` - Bridging, expect/actual, source-sets, catalog aliases, build-logic conventions. - - `.skills/compose-ui/` - Adaptive UI, placeholders, string resources. - - `.skills/navigation-and-di/` - JetBrains Navigation 3 & Koin 4.2+ annotations. - - `.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. - +For execution-focused recipes, see `docs/agent-playbooks/README.md`. - -- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically: - 1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user. - 2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds. - 3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails. -- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing. -- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code. -- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first). -- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing: - ``` - ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests - ``` - > **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required. - > For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job). - +## 1. Project Vision & Architecture +Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience. - -- **Codebase Search:** Use whatever search and navigation tools your environment provides (file search, grep/ripgrep, symbol lookup, semantic search, etc.) to map out project boundaries before coding. Prefer `rg` (ripgrep) over `grep` or `find` for raw text search. -- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt. -- **Fetch Up-to-Date Docs:** If your environment supports web search, MCP servers, or documentation lookup tools, actively query them for the latest documentation on Koin 4.x, JetBrains Navigation 3, and Compose Multiplatform 1.11. -- **Clone Reference Repos:** If documentation is insufficient, use shell commands to clone bleeding-edge KMP dependency repositories into the local `.agent_refs/` directory (git-ignored) to inspect their source and test suites. Recommended: - - `https://github.com/JetBrains/kotlin-multiplatform-dev-docs` (Official Docs) - - `https://github.com/InsertKoinIO/koin` (Koin Annotations 4.x) - - `https://github.com/JetBrains/compose-multiplatform` (Navigation 3, Adaptive UI) - - `https://github.com/JuulLabs/kable` (BLE) - - `https://github.com/coil-kt/coil` (Coil 3 KMP) - - `https://github.com/ktorio/ktor` (Ktor Networking) -- **Formatting Hooks:** Always run `./gradlew spotlessApply` as an automatic formatting hook to fix style violations after editing. - +- **Language:** Kotlin (primary), AIDL. +- **Build System:** Gradle (Kotlin DSL). JDK 21 is REQUIRED. +- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0). +- **Flavors:** + - `fdroid`: Open source only, no tracking/analytics. + - `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production"). +- **Core Architecture:** Modern Android Development (MAD) with KMP core. + - **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all. + - **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. + - **UI:** Jetpack Compose Multiplatform (Material 3). + - **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`. + - **Navigation:** JetBrains Navigation 3 (Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`. + - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`. + - **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints. + - **Database:** Room KMP. - -`AGENTS.md` is the single source of truth for agent instructions. Agent-specific files redirect here: -- `.github/copilot-instructions.md` — Copilot redirect to `AGENTS.md`. -- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions. -- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly. +## 2. Codebase Map -Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed. - +| Directory | Description | +| :--- | :--- | +| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. | +| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). | +| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). | +| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. | +| `core/model` | Domain models and common data structures. | +| `core:proto` | Protobuf definitions (Git submodule). | +| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. | +| `core:database` | Room KMP database implementation. | +| `core:datastore` | Multiplatform DataStore for preferences. | +| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). | +| `core:domain` | Pure KMP business logic and UseCases. | +| `core:data` | Core manager implementations and data orchestration. | +| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). | +| `core:di` | Common DI qualifiers and dispatchers. | +| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies per feature domain (e.g., `SettingsRoute`, `NodesRoute`). `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence — new routes are registered at compile time. | +| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | +| `core:service` | KMP service layer; Android bindings stay in `androidMain`. | +| `core:api` | Public AIDL/API integration module for external clients. | +| `core:prefs` | KMP preferences layer built on DataStore abstractions. | +| `core:barcode` | Barcode scanning (Android-only). | +| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. | +| `core/ble/` | Bluetooth Low Energy stack using Kable. | +| `core/resources/` | Centralized string and image resources (Compose Multiplatform). | +| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** | +| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. | +| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Scans for provisioning devices, lists available networks, applies credentials. Uses `core:ble` Kable abstractions. | +| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi via Kable/Ktor), native Nordic Secure DFU protocol (pure KMP, no Nordic library), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | +| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | +| `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app showing `core:api` service integration. Do not add code here. See `core/api/README.md` for the current integration guide. | - -- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application. -- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly. -- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated. -- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code. -- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds. -- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist. -- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution. -- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them. -- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules. -- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change. - +## 3. Development Guidelines & Coding Standards - -These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this -section. +### A. UI Development (Jetpack Compose) +- **Material 3:** The app uses Material 3. +- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. +- **String formatting:** CMP's `stringResource(res, args)` / `getString(res, args)` only support `%N$s` (string) and `%N$d` (integer) positional specifiers. Float formats like `%N$.1f` are NOT supported — they pass through unsubstituted. For float values, pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass the result as a `%N$s` string arg. Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings, since CMP does not convert `%%` to `%`. For JVM-only code using `formatString()` (which wraps `String.format()`), full printf specifiers including `%N$.Nf` and `%%` are supported. +- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). +- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable. +- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. +- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets. +- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes, `extraPane()`, and draggable dividers (`VerticalDragHandle` + `paneExpansionState`) for widths ≥ 1200dp. +- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`. -- **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. - +### B. Logic & Data Layer +- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module. +- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives: + - `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`. + - `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`. + - `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`. + - `java.io.*` → Okio (`BufferedSource`/`BufferedSink`). Note: JetBrains now recommends `kotlinx-io` as the official Kotlin I/O library (built on Okio). This project standardizes on Okio directly; do not migrate without explicit decision. + - `kotlinx.coroutines.Dispatchers.IO` → `org.meshtastic.core.common.util.ioDispatcher` (expect/actual). Note: `Dispatchers.IO` is available in `commonMain` since kotlinx.coroutines 1.8.0, but this project uses the `ioDispatcher` wrapper for consistency. +- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`. +- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`. +- **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`. +- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model. +- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient. +- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(backStack: NavBackStack)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider` block. Do NOT define navigation graphs in platform-specific source sets. +- **Concurrency:** Use Kotlin Coroutines and Flow. +- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`. +- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry` blocks are scoped to the entry's backstack lifetime and cleared on pop. +- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves. +- **BLE:** All Bluetooth communication must route through `core:ble` using Kable. +- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`. +- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules. +- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available. +- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane properties (Android) or Gradle CLI (desktop) to enable remote license/funding fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone — that burns API calls on every PR check. +- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. +- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main. +- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. +- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code. +- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes. +- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. - -- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation. -- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style. -- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters. - +### C. Namespacing +- **Standard:** Use the `org.meshtastic.*` namespace for all code. +- **Legacy:** Maintain the `com.geeksville.mesh` Application ID. + +## 4. Execution Protocol + +### A. Environment Setup +1. **JDK 21 MUST be used** to prevent Gradle sync/build failures. +2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`: + ```properties + MAPS_API_KEY=dummy_key + datadogApplicationId=dummy_id + datadogClientToken=dummy_token + ``` + +### B. Strict Execution Commands +Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues. + +**Baseline (recommended order):** +```bash +./gradlew clean +./gradlew spotlessCheck +./gradlew spotlessApply +./gradlew detekt +./gradlew assembleDebug +./gradlew test allTests +``` + +**Testing:** +```bash +# Full host-side unit test run (required — see note below): +./gradlew test allTests + +# Pure-Android / pure-JVM modules only (app, desktop, core:api, core:barcode, feature:widget, mesh_service_example): +./gradlew test + +# KMP modules only (all core:* KMP + all feature:* KMP modules — jvmTest + testAndroidHostTest + iosSimulatorArm64Test): +./gradlew allTests + +# CI-aligned flavor-explicit Android unit tests: +./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest + +./gradlew connectedAndroidTest # Run instrumented tests +./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests +./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks +``` + +> **Why `test allTests` and not just `test`:** +> In KMP modules, the `test` task name is **ambiguous** — Gradle matches both `testAndroid` and +> `testAndroidHostTest` and refuses to run either, silently skipping all 25 KMP modules. +> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP Gradle plugin for each +> KMP module. It runs `jvmTest`, `testAndroidHostTest` (where declared with `withHostTest {}`), and +> `iosSimulatorArm64Test` (disabled at execution — iOS targets are compile-only). Conversely, +> `allTests` does **not** cover the pure-Android modules (`:app`, `:core:api`, `:core:barcode`, +> `:feature:widget`, `:mesh_service_example`, `:desktop`), which is why both are needed. + +*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.* + +**CI workflow conventions (GitHub Actions):** +- Reusable CI in `.github/workflows/reusable-check.yml` is structured as four parallel job groups: + 1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation. Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs. + 2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`): + - `shard-core`: `allTests` for all `core:*` KMP modules. + - `shard-feature`: `allTests` for all `feature:*` KMP modules. + - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`). + Each shard generates its own Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. + Downstream jobs (test-shards, android-check, build-desktop) use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. + 3. **`android-check`** — Builds APKs and runs instrumented tests (depends on `lint-check`). + 4. **`build-desktop`** — Desktop packaging (depends on `lint-check`). +- Test sharding uses `fail-fast: false` so a failure in one shard does not cancel the others. +- JUnit Platform parallel execution is enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races (JVM-global singleton used by 19+ ViewModel test classes). Cross-module parallelism comes from Gradle forks (`maxParallelForks`). +- `test-retry` plugin (maxRetries=2, maxFailures=10) is applied to all module types: `AndroidApplicationConventionPlugin`, `AndroidLibraryConventionPlugin`, and `KmpLibraryConventionPlugin`. +- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API. +- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`. +- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch). +- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI. +- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes. +- **Runner strategy (three tiers):** + - **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). These run only shell scripts or GitHub API calls and benefit from ARM runners' shorter queue times. + - **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin where possible for reproducibility. + - **Desktop runners:** Reusable CI uses `ubuntu-24.04` for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`. +- **CI Gradle properties:** `gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties` before any Gradle invocation. Key CI overrides: `org.gradle.daemon=false` (single-use runners), `kotlin.incremental=false` (fresh checkouts), `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon, VFS watching disabled, workers capped at 4, `org.gradle.isolated-projects=true` for better parallelism. Disables unused Android build features (`resvalues`, `shaders`). This follows the nowinandroid `ci-gradle.properties` pattern. +- **CI optimization strategies (2026):** Applied comprehensive CI optimizations (P0-P3): + - **P0 (merged Gradle invocations):** `lint-check` merges spotlessCheck, detekt, android lint, and kmpSmokeCompile into a single Gradle invocation to avoid 3x cold-start overhead. Uses `filter: 'blob:none'` for blobless git clone. Switches submodules from `'recursive'` to boolean (saves overhead on nested submodule discovery). + - **P1 (reduced PR overhead):** Added `run_coverage` workflow input (default: true); PRs skip Kover reports via conditional tasks in test-shards matrix. Increased `maxParallelForks` in CI to use all available processors (4 on standard runners) when `ci=true` property is set, vs. half locally for system responsiveness. + - **P2 (build feature optimization):** Detekt disables non-essential report formats in CI (html, txt, md); retains only xml + sarif for GitHub annotations. Disables unused Android build features (resvalues, shaders) in `ci-gradle.properties`. + - **P3 (structural improvement):** Removed `verify-check-changes-filter` from `validate-and-build` dependencies; it now runs in parallel as a standalone required check instead of gating the main build. +- **`maxParallelForks` CI logic:** ProjectExtensions.kt line ~79 checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true` to enable this. +- **Detekt report formats:** Detekt.kt line ~44 checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are required for GitHub reporting. +- **KMP Smoke Compile:** Use `./gradlew kmpSmokeCompile` instead of listing individual module compile tasks. The `kmpSmokeCompile` lifecycle task (registered in `RootConventionPlugin`) auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks. +- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` failures when Robolectric downloads instrumented SDK jars. The cache key is `robolectric-{version}-sdk{level}` — update it when bumping the Robolectric version in `libs.versions.toml` or the SDK level in `robolectric.properties` / `@Config(sdk = ...)`. +- **`mavenLocal()` gated:** The `mavenLocal()` repository is disabled by default to prevent CI cache poisoning. For local JitPack testing, pass `-PuseMavenLocal` to Gradle. +- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent the agent from getting stuck in an interactive prompt. +- **Text Search:** Prefer using `rg` (ripgrep) over `grep` or `find` for fast text searching across the codebase. + +### C. Documentation Sync +`AGENTS.md` is the single source of truth for agent instructions. `.github/copilot-instructions.md` and `GEMINI.md` are thin stubs that redirect here — do NOT duplicate content into them. + +When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md` as needed. + +## 5. Troubleshooting +- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts. +- **Missing Secrets:** Check `local.properties`. +- **JDK Version:** JDK 21 is required. +- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist. +- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`). \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index eb5cd5e5c..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,9 +0,0 @@ -# Meshtastic Android - Claude Code Guide - -@AGENTS.md - -## Claude-Specific Instructions - -- **Think First:** Always outline your step-by-step reasoning inside `` tags before writing code or shell commands. Claude models perform significantly better on complex KMP tasks when they "think out loud" first. -- **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task. -- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `` section. diff --git a/GEMINI.md b/GEMINI.md index 72a350afb..9076b718e 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,6 +1,6 @@ -# Meshtastic Android - Google Gemini Guide +# Meshtastic Android - Agent Guide -> **Note:** The canonical instructions for all AI Agents have been deduplicated. +**Canonical instructions live in [`AGENTS.md`](AGENTS.md).** This file exists as `GEMINI.md` so Google Gemini discovers it automatically. -You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. -After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks. +See [AGENTS.md](AGENTS.md) for architecture, conventions, execution protocol, and coding standards. +See [docs/agent-playbooks/README.md](docs/agent-playbooks/README.md) for version baselines and task recipes. diff --git a/Gemfile.lock b/Gemfile.lock index cf6a1b9c0..de497cc4a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - addressable (2.9.0) + addressable (2.8.8) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1240.0) - aws-sdk-core (3.245.0) + aws-partitions (1.1213.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -17,11 +17,11 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.123.0) - aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-kms (1.121.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.219.0) - aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -29,7 +29,7 @@ GEM babosa (1.0.4) base64 (0.2.0) benchmark (0.5.0) - bigdecimal (4.1.2) + bigdecimal (4.0.1) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -68,11 +68,11 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.4) + faraday-retry (1.0.3) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.1) - fastlane (2.233.0) + fastimage (2.4.0) + fastlane (2.232.2) CFPropertyList (>= 2.3, < 4.0.0) abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) @@ -92,7 +92,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.1.0) + fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -122,9 +122,10 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.1.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.99.0) + google-apis-androidpublisher_v3 (0.95.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -138,15 +139,15 @@ GEM google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.17.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.61.0) + google-apis-storage_v1 (0.59.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (2.1.1) faraday (>= 1.0, < 3.a) - google-cloud-errors (1.6.0) - google-cloud-storage (1.59.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.58.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -168,13 +169,13 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.19.4) + json (2.18.1) jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.20.1) + multi_json (1.19.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) @@ -184,13 +185,13 @@ GEM os (1.1.4) ostruct (0.6.3) plist (3.7.2) - public_suffix (7.0.5) - rake (13.4.2) + public_suffix (7.0.2) + rake (13.3.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.4.1) + retriable (3.1.2) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) @@ -204,6 +205,7 @@ GEM simctl (1.6.10) CFPropertyList naturally + sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/SOUL.md b/SOUL.md new file mode 100644 index 000000000..793387334 --- /dev/null +++ b/SOUL.md @@ -0,0 +1,31 @@ +# Meshtastic-Android: AI Agent Soul (SOUL.md) + +This file defines the personality, values, and behavioral framework of the AI agent for this repository. + +## 1. Core Identity +I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-Android codebase while maintaining its integrity as a secure, decentralized communication tool. I am not just a "helpful assistant"; I am a senior peer programmer who takes ownership of the technical stack. + +## 2. Core Truths & Values +- **Privacy is Paramount:** Meshtastic is used for off-grid, often sensitive communication. I treat user data, location info, and cryptographic keys with extreme caution. I will never suggest logging PII or secrets. +- **Code is a Liability:** I prefer simple, readable code over clever abstractions. I remove dead code and minimize dependencies wherever possible. +- **Decentralization First:** I prioritize architectural patterns that support offline-first and peer-to-peer logic. +- **MAD & KMP are the Standard:** Modern Android Development (Compose, Koin, Coroutines) and Kotlin Multiplatform are not suggestions; they are the foundation. I resist introducing legacy patterns unless absolutely required for OS compatibility. + +## 3. Communication Style (The "Vibe") +- **Direct & Concise:** I skip the fluff. I provide technical rationale first. +- **Opinionated but Grounded:** I provide clear technical recommendations based on established project conventions. +- **Action-Oriented:** I don't just "talk" about code; I implement, test, and format it. + +## 4. Operational Boundaries +- **Zero Lint Tolerance (for code changes):** I consider a coding task incomplete if `detekt` fails or `spotlessCheck` is not passing for touched modules. +- **Test-Driven Execution (where feasible):** For bug fixes, I should reproduce the issue with a test before fixing it when practical. For new features, I should add appropriate verification logic. +- **Dependency Discipline:** I never add a library without checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity. +- **No Hardcoded Strings:** I will refuse to add hardcoded UI strings, strictly adhering to the `:core:resources` KMP resource system. + +## 5. Evolution +I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers. + +For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth. +For implementation recipes and verification scope, I use `docs/agent-playbooks/README.md`. + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d239d0530..77302534e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -171,6 +171,8 @@ configure { } else { signingConfig = signingConfigs.getByName("debug") } + isMinifyEnabled = true + isShrinkResources = true isDebuggable = false } } @@ -241,10 +243,9 @@ dependencies { implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.material) - implementation(libs.compose.multiplatform.animation) - implementation(libs.compose.multiplatform.material3) - implementation(libs.compose.multiplatform.ui.tooling.preview) - implementation(libs.compose.multiplatform.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.ui.text) implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.appwidget.preview) implementation(libs.androidx.glance.material3) @@ -264,6 +265,7 @@ dependencies { implementation(libs.usb.serial.android) implementation(libs.androidx.work.runtime.ktx) implementation(libs.koin.android) + implementation(libs.koin.androidx.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.androidx.workmanager) implementation(libs.koin.annotations) @@ -279,6 +281,7 @@ dependencies { googleImplementation(libs.maps.compose) googleImplementation(libs.maps.compose.utils) googleImplementation(libs.maps.compose.widgets) + googleImplementation(libs.dd.sdk.android.compose) googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.rum) googleImplementation(libs.dd.sdk.android.session.replay) @@ -294,6 +297,12 @@ dependencies { fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } fdroidImplementation(libs.osmbonuspack) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.koin.test) + testImplementation(kotlin("test-junit")) testImplementation(libs.androidx.work.testing) testImplementation(libs.koin.test) @@ -301,7 +310,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) - testImplementation(libs.compose.multiplatform.ui.test) + testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.glance.appwidget) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index de2b3144c..995f659ba 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,45 +1,61 @@ -# ============================================================================ -# Meshtastic Android — ProGuard / R8 rules for release minification -# ============================================================================ -# Open-source project: obfuscation and optimization are disabled. We rely on -# tree-shaking (unused code removal) for APK size reduction. +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. # -# 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. -# ============================================================================ +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html -# ---- General ---------------------------------------------------------------- +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} -# Open-source — no need to obfuscate --dontobfuscate +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable -# Disable R8 optimization passes. Tree-shaking (unused code removal) still -# runs — only method-body rewrites and call-site transformations are suppressed. -# -# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on -# Composer.() and ComposerImpl.(), plus -assumevalues on -# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives -# let R8 rewrite *call sites* (class-init triggers, flag reads) even when the -# target classes are preserved by -keep rules. The result is that the Compose -# recomposer/frame-clock/animation state machines silently freeze on their -# first frame in release builds. -dontoptimize is the only directive that -# disables processing of -assumenosideeffects/-assumevalues. See #5146. --dontoptimize +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile -# Dump the full merged R8 configuration (app rules + all library consumer rules) -# for auditing. Inspect this file after a release build to see what libraries inject. --printconfiguration build/outputs/mapping/r8-merged-config.txt +# Room KMP: preserve generated database constructor (required for R8/ProGuard) +-keep class * extends androidx.room.RoomDatabase { (); } -# ---- Networking (transitive references from Ktor on Android) ---------------- +# Needed for protobufs +-keep class com.google.protobuf.** { *; } +-keep class org.meshtastic.proto.** { *; } +# Networking -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** -# Compose runtime/ui/animation/foundation/material3 keep rules now live in -# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard) -# get the same defence-in-depth coverage against CMP 1.11 optimizer folding. +# ? +-dontwarn java.lang.reflect.** +-dontwarn com.google.errorprone.annotations.** + +# Our app is opensource no need to obsfucate +-dontobfuscate +-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable + +# Koin DI: prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException +# replacing Koin's InstanceCreationException in stack traces, making crashes undiagnosable). +-keep class org.koin.core.error.** { *; } + +# R8 optimization for Kotlin null checks (AGP 9.0+) +-processkotlinnullchecks remove + +# Compose Multiplatform resources: keep the resource library internals and generated Res +# accessor classes so R8 does not tree-shake the resource loading infrastructure. +# Without these rules the fdroid flavor (which has fewer transitive Compose dependencies +# than google) crashes at startup with a misleading URLDecodeException due to R8 +# exception-class merging (see Koin keep rule above). +-keep class org.jetbrains.compose.resources.** { *; } +-keep class org.meshtastic.core.resources.** { *; } + +# Nordic BLE +-dontwarn no.nordicsemi.kotlin.ble.environment.android.mock.** +-keep class no.nordicsemi.kotlin.ble.environment.android.mock.** { *; } +-keep class no.nordicsemi.kotlin.ble.environment.android.compose.** { *; } diff --git a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt new file mode 100644 index 000000000..4cbf88356 --- /dev/null +++ b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.filter + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.test.KoinTest +import org.koin.test.inject +import org.meshtastic.core.repository.FilterPrefs +import org.meshtastic.core.repository.MessageFilter + +@RunWith(AndroidJUnit4::class) +class MessageFilterIntegrationTest : KoinTest { + + private val filterPrefs: FilterPrefs by inject() + + private val filterService: MessageFilter by inject() + + @org.junit.Ignore("Flaky integration test, needs Koin test rule setup") + @Test + fun filterPrefsIntegration() = runTest { + filterPrefs.setFilterEnabled(true) + filterPrefs.setFilterWords(setOf("test", "spam")) + // Wait briefly for DataStore to process the writes and flows to emit + kotlinx.coroutines.delay(100) + filterService.rebuildPatterns() + + assertTrue(filterService.shouldFilter("this is a test message")) + assertTrue(filterService.shouldFilter("spam content")) + } +} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index b4d0e1bbd..54935b422 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -77,6 +77,8 @@ import org.meshtastic.app.map.cluster.RadiusMarkerClusterer import org.meshtastic.app.map.component.CacheLayout import org.meshtastic.app.map.component.DownloadButton import org.meshtastic.app.map.component.EditWaypointDialog +import org.meshtastic.app.map.component.MapButton +import org.meshtastic.app.map.component.MapControlsOverlay import org.meshtastic.app.map.model.CustomTileSource import org.meshtastic.app.map.model.MarkerWithLabel import org.meshtastic.core.common.gpsDisabled @@ -128,8 +130,6 @@ import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Waypoint import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable @@ -861,9 +861,15 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { onDismiss = onDismiss, negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, ) { - 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)) + Text( + modifier = Modifier.padding(16.dp), + text = + stringResource( + Res.string.map_cache_info, + cacheCapacity / (1024.0 * 1024.0), + currentCacheUsage / (1024.0 * 1024.0), + ), + ) } } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt index 3cc0dbaf0..04f896d18 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt @@ -124,21 +124,20 @@ fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () return polyline } -fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List { +fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List { val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation) - val markers = - positions.map { pos -> - Marker(this).apply { - icon = navIcon - rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat() - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7) - setOnMarkerClickListener { _, _ -> - onClick(pos.time) - true - } + val markers = positions.map { + Marker(this).apply { + icon = navIcon + rotation = ((it.ground_track ?: 0) * 1e-5).toFloat() + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + position = GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) + setOnMarkerClickListener { _, _ -> + onClick() + true } } + } overlays.addAll(markers) return markers diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt index 77b595d88..0178a498e 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -26,17 +26,9 @@ import org.meshtastic.proto.Position * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain * [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation * ([NodeTrackOsmMap]). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. */ @Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { +fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = Modifier) { val vm = koinViewModel() vm.setDestNum(destNum) NodeTrackOsmMap( @@ -44,7 +36,5 @@ fun NodeTrackMap( applicationId = vm.applicationId, mapStyleId = vm.mapStyleId, modifier = modifier, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, ) } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt index a6aec4c2d..64d207a6e 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt @@ -42,6 +42,7 @@ import org.meshtastic.app.map.addCopyright import org.meshtastic.app.map.addPolyline import org.meshtastic.app.map.addPositionMarkers import org.meshtastic.app.map.addScaleBarOverlay +import org.meshtastic.app.map.component.MapControlsOverlay import org.meshtastic.app.map.model.CustomTileSource import org.meshtastic.app.map.rememberMapViewWithLifecycle import org.meshtastic.core.common.util.nowSeconds @@ -49,7 +50,6 @@ import org.meshtastic.core.model.util.GeoConstants.DEG_D import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.last_heard_filter_label import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.proto.Position import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint @@ -61,10 +61,8 @@ import kotlin.math.roundToInt * * Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter] * from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a - * minimal [MapControlsOverlay][org.meshtastic.feature.map.component.MapControlsOverlay] with a track time filter slider - * so users can adjust the time range directly from the map. - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. + * minimal [MapControlsOverlay][org.meshtastic.app.map.component.MapControlsOverlay] with a track time filter slider so + * users can adjust the time range directly from the map. * * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or * location tracking. It is designed to be embedded inside the position-log adaptive layout. @@ -75,8 +73,6 @@ fun NodeTrackOsmMap( applicationId: String, mapStyleId: Int, modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, mapViewModel: MapViewModel = koinViewModel(), ) { val density = LocalDensity.current @@ -113,15 +109,7 @@ fun NodeTrackOsmMap( map.addCopyright() map.addScaleBarOverlay(density) map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) } - // Center on selected position - if (selectedPositionTime != null) { - val selected = filteredPositions.find { it.time == selectedPositionTime } - if (selected != null) { - val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D) - map.controller.animateTo(point) - } - } + map.addPositionMarkers(filteredPositions) {} }, ) diff --git a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt index 0583dd78e..bf42494e5 100644 --- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt +++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt @@ -26,6 +26,7 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Severity import com.datadog.android.Datadog import com.datadog.android.DatadogSite +import com.datadog.android.compose.enableComposeActionTracking import com.datadog.android.core.configuration.Configuration import com.datadog.android.log.Logger import com.datadog.android.log.Logs @@ -159,6 +160,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic .trackFrustrations(false) // Disable click-tracking based frustration detection .trackLongTasks() .trackNonFatalAnrs(true) + .enableComposeActionTracking() // Required: activates runtime consumption of Compose semantics tags .setSessionSampleRate(sampleRate) .build() Rum.enable(rumConfiguration) diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt index c8f2f3fee..0418d76b7 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -97,6 +96,8 @@ import org.meshtastic.app.map.component.ClusterItemsListDialog import org.meshtastic.app.map.component.CustomMapLayersSheet import org.meshtastic.app.map.component.CustomTileProviderManagerSheet import org.meshtastic.app.map.component.EditWaypointDialog +import org.meshtastic.app.map.component.MapButton +import org.meshtastic.app.map.component.MapControlsOverlay import org.meshtastic.app.map.component.MapFilterDropdown import org.meshtastic.app.map.component.MapTypeDropdown import org.meshtastic.app.map.component.NodeClusterMarkers @@ -135,8 +136,6 @@ import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.feature.map.tracerouteNodeSelection import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Position @@ -156,12 +155,7 @@ sealed interface GoogleMapMode { data object Main : GoogleMapMode /** Focused node position track: polyline + gradient markers for historical positions. */ - data class NodeTrack( - val focusedNode: Node?, - val positions: List, - val selectedPositionTime: Int? = null, - val onPositionSelected: ((Int) -> Unit)? = null, - ) : GoogleMapMode + data class NodeTrack(val focusedNode: Node?, val positions: List) : GoogleMapMode /** Traceroute visualization: offset forward/return polylines + hop markers. */ data class Traceroute( @@ -430,17 +424,6 @@ fun MapView( Logger.d { "Error centering track map: ${e.message}" } } } - - // Animate to selected position marker when card is tapped in the list - LaunchedEffect(mode.selectedPositionTime) { - val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect - val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect - try { - cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng())) - } catch (e: IllegalStateException) { - Logger.d { "Error animating to selected position: ${e.message}" } - } - } } if (mode is GoogleMapMode.Traceroute) { @@ -594,8 +577,6 @@ fun MapView( sortedPositions = sortedTrackPositions, displayUnits = displayUnits, myNodeNum = myNodeNum, - selectedPositionTime = mode.selectedPositionTime, - onPositionSelected = mode.onPositionSelected, ) } } @@ -827,24 +808,17 @@ private fun MainMapContent( * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a * [TripOrigin] dot with an info-window on tap. - * - * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and - * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization. */ @OptIn(MapsComposeExperimentalApi::class) @Composable -@Suppress("LongMethod") private fun NodeTrackOverlay( focusedNode: Node, sortedPositions: List, displayUnits: DisplayUnits, myNodeNum: Int?, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, ) { val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite val activeNodeZIndex = if (isHighPriority) 5f else 4f - val selectedColor = MaterialTheme.colorScheme.primary sortedPositions.forEachIndexed { index, position -> key(position.time) { @@ -855,23 +829,13 @@ private fun NodeTrackOverlay( } else { 1f } - val isSelected = position.time == selectedPositionTime - val color = - if (isSelected) { - selectedColor - } else { - Color(focusedNode.colors.second).copy(alpha = alpha) - } + val color = Color(focusedNode.colors.second).copy(alpha = alpha) if (index == sortedPositions.lastIndex) { MarkerComposable( state = markerState, zIndex = activeNodeZIndex, alpha = if (isHighPriority) 1.0f else 0.9f, - onClick = { - onPositionSelected?.invoke(position.time) - false // Allow default info window behavior - }, ) { NodeChip(node = focusedNode) } @@ -880,18 +844,13 @@ private fun NodeTrackOverlay( state = markerState, title = stringResource(Res.string.position), snippet = formatAgo(position.time), - zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha, - onClick = { - onPositionSelected?.invoke(position.time) - false // Allow default info window behavior - }, + zIndex = 1f + alpha, infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) }, ) { Icon( imageVector = MeshtasticIcons.TripOrigin, contentDescription = stringResource(Res.string.track_point), tint = color, - modifier = if (isSelected) Modifier.size(32.dp) else Modifier, ) } } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index e4eabbb76..70ff4858d 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -28,11 +28,7 @@ import com.google.android.gms.maps.model.TileProvider import com.google.android.gms.maps.model.UrlTileProvider import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.MapType -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.isSuccess -import io.ktor.utils.io.jvm.javaio.toInputStream +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -49,7 +45,6 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.app.map.model.CustomTileProviderConfig import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs import org.meshtastic.app.map.repository.CustomTileProviderRepository -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.NodeRepository @@ -82,8 +77,6 @@ data class MapCameraPosition( @KoinViewModel class MapViewModel( private val application: Application, - private val dispatchers: CoroutineDispatchers, - private val httpClient: HttpClient, mapPrefs: MapPrefs, private val googleMapsPrefs: GoogleMapsPrefs, nodeRepository: NodeRepository, @@ -411,7 +404,7 @@ class MapViewModel( } private fun loadPersistedLayers() { - viewModelScope.launch(dispatchers.io) { + viewModelScope.launch(Dispatchers.IO) { try { val layersDir = File(application.filesDir, "map_layers") if (layersDir.exists() && layersDir.isDirectory) { @@ -419,33 +412,32 @@ class MapViewModel( if (persistedLayerFiles != null) { val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value - val loadedItems = - persistedLayerFiles.mapNotNull { file -> - if (file.isFile) { - val layerType = - when (file.extension.lowercase()) { - "kml", - "kmz", - -> LayerType.KML - "geojson", - "json", - -> LayerType.GEOJSON - else -> null - } - - layerType?.let { - val uri = Uri.fromFile(file) - MapLayerItem( - name = file.nameWithoutExtension, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = it, - ) + val loadedItems = persistedLayerFiles.mapNotNull { file -> + if (file.isFile) { + val layerType = + when (file.extension.lowercase()) { + "kml", + "kmz", + -> LayerType.KML + "geojson", + "json", + -> LayerType.GEOJSON + else -> null } - } else { - null + + layerType?.let { + val uri = Uri.fromFile(file) + MapLayerItem( + name = file.nameWithoutExtension, + uri = uri, + isVisible = !hiddenLayerUrls.contains(uri.toString()), + layerType = it, + ) } + } else { + null } + } val networkItems = googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString -> @@ -558,7 +550,7 @@ class MapViewModel( } } - private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) { + private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) { try { val inputStream = application.contentResolver.openInputStream(uri) val directory = File(application.filesDir, "map_layers") @@ -629,7 +621,7 @@ class MapViewModel( } private suspend fun deleteFileToInternalStorage(uri: Uri) { - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { try { val file = uri.toFile() if (file.exists()) { @@ -644,15 +636,11 @@ class MapViewModel( @Suppress("Recycle") suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? { val uriToLoad = layerItem.uri ?: return null - return withContext(dispatchers.io) { + return withContext(Dispatchers.IO) { try { if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) { - val response = httpClient.get(uriToLoad.toString()) - if (!response.status.isSuccess()) { - Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" } - return@withContext null - } - response.bodyAsChannel().toInputStream() + val url = java.net.URL(uriToLoad.toString()) + java.io.BufferedInputStream(url.openStream()) } else { application.contentResolver.openInputStream(uriToLoad) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt index fd9272579..fb5f682ed 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconToggleButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -126,10 +125,7 @@ fun CustomMapLayersSheet( } } } - IconToggleButton( - checked = layer.isVisible, - onCheckedChange = { onToggleVisibility(layer.id) }, - ) { + IconButton(onClick = { onToggleVisibility(layer.id) }) { Icon( imageVector = if (layer.isVisible) { diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt index 2f7244b97..513957c61 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -31,28 +31,11 @@ import org.meshtastic.proto.Position * [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode, * which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track * filter). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. */ @Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { +fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = Modifier) { val vm = koinViewModel() vm.setDestNum(destNum) val focusedNode by vm.node.collectAsStateWithLifecycle() - MapView( - modifier = modifier, - mode = - GoogleMapMode.NodeTrack( - focusedNode = focusedNode, - positions = positions, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, - ), - ) + MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions)) } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt index 668dedbaa..e33fb1f8c 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt @@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers @Module @ComponentScan("org.meshtastic.app.map") @@ -36,10 +36,9 @@ class GoogleMapsKoinModule { @Single @Named("GoogleMapsDataStore") - fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = - PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), - scope = CoroutineScope(dispatchers.io + SupervisorJob()), - produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, - ) + fun provideGoogleMapsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, + ) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f7d2ce900..43468c69d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -288,7 +288,7 @@ + android:resource="@xml/local_stats_widget_info" /> diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json index ffdb465d6..4d74c2b5a 100644 --- a/app/src/main/assets/firmware_releases.json +++ b/app/src/main/assets/firmware_releases.json @@ -24,19 +24,12 @@ } ], "alpha": [ - { - "id": "v2.7.22.96dd647", - "title": "Meshtastic Firmware 2.7.22.96dd647 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.22.96dd647", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.22.96dd647/firmware-2.7.22.96dd647.json", - "release_notes": "## 🐛 Bug fixes and maintenance\r\n\r\n- Fix(native): implement BinarySemaphorePosix with proper pthread synchronization by @iannucci in https://github.com/meshtastic/firmware/pull/9895\r\n- Meshtasticd: Add configs for ebyte-ecb41-pge (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10086\r\n- Meshtasticd: Add configs for forlinx-ok3506-s12 (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10087\r\n- Fix Linux Input enable logic by @jp-bennett in https://github.com/meshtastic/firmware/pull/10093\r\n- PPA: Use SFTP method for uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/10138\r\n- Switch PlatformIO deps from PIO Registry to tagged GitHub zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/10142\r\n- Fix display method to use const qualifier for previousBuffer pointer by @vidplace7 in https://github.com/meshtastic/firmware/pull/10146\r\n- Fix last cppcheck issue by @caveman99 in https://github.com/meshtastic/firmware/pull/10154\r\n- Fix heap blowout on TBeams by @thebentern in https://github.com/meshtastic/firmware/pull/10155\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update meshtastic-esp32_https_server digest to 0c71f38 by @app/renovate in https://github.com/meshtastic/firmware/pull/10081\r\n- Update meshtastic-st7789 digest to 222554e by @app/renovate in https://github.com/meshtastic/firmware/pull/10121\r\n- Update actions/github-script action to v9 by @app/renovate in https://github.com/meshtastic/firmware/pull/10122\r\n- Update meshtastic-st7789 digest to 7228c49 by @app/renovate in https://github.com/meshtastic/firmware/pull/10131\r\n- Update pnpm/action-setup action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10132\r\n- Update meshtastic-st7789 digest to 4d957e7 by @app/renovate in https://github.com/meshtastic/firmware/pull/10134\r\n- Update meshtastic-st7789 digest to a787bee by @app/renovate in https://github.com/meshtastic/firmware/pull/10147\r\n- Update softprops/action-gh-release action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/10150\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.21.1370b23...v2.7.22.96dd647" - }, { "id": "v2.7.21.1370b23", "title": "Meshtastic Firmware 2.7.21.1370b23 Alpha", "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.21.1370b23", "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.21.1370b23/firmware-2.7.21.1370b23.json", - "release_notes": "> [!WARNING]\r\n> Due to resource constraints, the HTTP server is deprecated on original-generation ESP32 devices and should not be relied on going forward. \r\n> Support continues on ESP32-S3 and other newer ESP32 generations.\r\n\r\n## 🚀 Enhancements\r\n\r\n- Add T5-4.7-S3 Epaper Pro support. #6625\r\n- Apply Thailand NBTC 920-925 MHz limits (27 dBm, 10% duty cycle). #9827\r\n- Switch nRF52840 builds to C++17. #9874\r\n- Clean up SEN5X warnings. #9884\r\n- Refactor BaseUI emotes. #9896\r\n- Add spoof detection in `UdpMulticastHandler`. #9905\r\n- Enable LNA by default on Heltec v4.3. #9906\r\n- Rotate MUI for the Heltec V4 + TFT expansion kit. #9938\r\n- Make `hexDump()` take a `const` buffer. #9944\r\n- Add `meshtasticd` config metadata. #10001\r\n- Add `MESHTASTIC_EXCLUDE_ACCELEROMETER`. #10004\r\n- Adapt MUI WiFi map tile downloads for Heltec V4. #10011\r\n- Fix Mesh-tab WiFi map and exclude-screen behavior. #10038\r\n- Include Thinknode M5 minor fixes. #10049\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Remove GPS baudrate locking on the Seeed Xiao S3 kit. #9374\r\n- Fix RAK4631 Ethernet gateway API connection loss after W5100S brownouts. #9754\r\n- Fix W5100S socket exhaustion blocking MQTT and additional TCP clients. #9770\r\n- Fix traceroute over MQTT when the uplink node is encrypted. #9798\r\n- Extend Debian sourcedeb cache expiration. #9858\r\n- Fix T-LoRA Pager SPI bus sharing between SX1262 and the SD card. #9870\r\n- Update `ESP8266Audio` to the Meshtastic fork for compatibility. #9872\r\n- Fix `rak_wismeshtag` low-voltage reboot hangs after app configuration. #9897\r\n- Preserve `pki_encrypted` and `public_key` when relaying UDP multicast packets to radio. #9916\r\n- Add the new RAK 13302 power curve. #9929\r\n- Fix MQTT settings not persisting when the broker is unreachable. #9934\r\n- Fix BMP detection by not returning early during BME address scans. #9935\r\n- Enforce infrastructure-role minimums even when scaling is disabled. #9937\r\n- Fix traceroute hop rendering for `ffff` / unknown-dB hops. #9945\r\n- Fix NodeInfo suppression so it only applies to external requests. #9947\r\n- Enable touch-to-backlight on T-Echo, not just T-Echo Plus. #9953\r\n- Prevent licensed users from rebroadcasting packets to or from unlicensed users. #9958\r\n- Add the `heltec_mesh_node_t096` board. #9960\r\n- Add Cardputer-Adv I2S audio support. #9963\r\n- Fix the Cyrillic OLED double-space issue. #9971\r\n- Add `LED_BUILTIN` for `tlora_v1`. #9973\r\n- Add a timeout for PPA uploads. #9989\r\n- Exclude the web server, Paxcounter, and a few other components on original ESP32 boards to avoid IRAM overflow. #10005\r\n- Rework External Notifications logic. #10006\r\n- Improve STM32WL support. #10015\r\n- Configure NFC pins as GPIO for older bootloaders. #10016\r\n- Fix `TransmitHistory` epoch handling. #10017\r\n- Inherit `build_unflags` for `wio-sdk-wm1110`. #10034\r\n- Remove PSRAM from `tbeam` boards to reclaim IRAM. #10036\r\n- Move `t5s3_epaper_inkhud` to `extra`. #10037\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update `meshtastic-esp32_https_server` to digest `b78f12c`. #9851\r\n- Update `meshtastic/device-ui` through digests `622b034`, `f36d2a9`, `7b1485b`, and `1897dd1`. #9864 #9940 #10023 #10044 #10050\r\n- Update `GxEPD2` to `v1.6.8`. #9918\r\n- Update `pnpm/action-setup` to `v5`. #9926\r\n- Update `dorny/test-reporter` to `v3`. #9981\r\n- Clean up LewisHe library references and dependency matching, and tighten Renovate scheduling. #10007 #10008 #10039\r\n- Update `Adafruit_BME680` to `v2.0.6`. #10009\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.21.1370b23\r\n" + "release_notes": "> [!WARNING]\r\n> Due to resource constraints the HTTP server is deprecated on original-generation ESP32 devices and should not be relied on going forward.\r\n> Support continues to be available on ESP32-S3 and other newer ESP32 generations.\r\n\r\n## 🚀 Enhancements\r\n\r\n- T5-4.7-S3 Epaper Pro support by @mverch67 in https://github.com/meshtastic/firmware/pull/6625\r\n- Xiao NRF - define suitable i2c pins for the sub-variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/8866\r\n- Fix(MQTT): Send first MapReport as soon as possible by @ndoo in https://github.com/meshtastic/firmware/pull/8872\r\n- Feat/add sfa30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9372\r\n- Improved Periodic class by @harry-iii-lord in https://github.com/meshtastic/firmware/pull/9501\r\n- InkHUD: Allow non-system applets to subscribe to input events by @Vortetty in https://github.com/meshtastic/firmware/pull/9514\r\n- Cardputer Kit by @caveman99 in https://github.com/meshtastic/firmware/pull/9540\r\n- Skip header items when enabling the InkHUD menu cursor by @zeropt in https://github.com/meshtastic/firmware/pull/9552\r\n- ExternalNotification and StatusLED now call AmbientLighting to update… by @jp-bennett in https://github.com/meshtastic/firmware/pull/9554\r\n- BaseUI: Favorite Screen Signal Quality improvement by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9566\r\n- Add battery curve for T-Beam 1 watt by @jp-bennett in https://github.com/meshtastic/firmware/pull/9585\r\n- Add sdl libs for native builds by @jp-bennett in https://github.com/meshtastic/firmware/pull/9595\r\n- Log `rxBad` PacketHeaders with more info (`id`, `relay_node`) like `printPacket` by @compumike in https://github.com/meshtastic/firmware/pull/9614\r\n- Develop to master by @thebentern in https://github.com/meshtastic/firmware/pull/9618\r\n- Fix a lot of low level cppcheck warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9623\r\n- Convert `GPS*` global and some new in gps.cpp to `unique_ptr` by @Jorropo in https://github.com/meshtastic/firmware/pull/9628\r\n- Replace delete in RedirectablePrint.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9642\r\n- Replace delete in EInkDynamicDisplay.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9643\r\n- Replace delete in RadioInterface.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9645\r\n- Replace delete in CryptoEngine.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9649\r\n- Replace delete in AudioThread.h with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9651\r\n- Scaling tweaks by @NomDeTom in https://github.com/meshtastic/firmware/pull/9653\r\n- InkHUD: Favorite Map Applet by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9654\r\n- Fake IAQ values on Non-BSEC2 platforms like Platformio and the original ESP32 by @caveman99 in https://github.com/meshtastic/firmware/pull/9663\r\n- #9623 resolved a local shadow of next_key by converting it to int. by @caveman99 in https://github.com/meshtastic/firmware/pull/9665\r\n- Zip a few gitrefs down by @caveman99 in https://github.com/meshtastic/firmware/pull/9672\r\n- Limit http connections and add free heap check before allocating for SSL by @thebentern in https://github.com/meshtastic/firmware/pull/9693\r\n- Split module includes for AQ module by @oscgonfer in https://github.com/meshtastic/firmware/pull/9711\r\n- Align telemetry broadcast want_response behavior with traceroute by @thebentern in https://github.com/meshtastic/firmware/pull/9717\r\n- InkHUD: Nodelist cleanup by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9737\r\n- Add GPIO_DETECT_PA portduino config, and support 13302 detection with it by @jp-bennett in https://github.com/meshtastic/firmware/pull/9741\r\n- Remove unused global rIf that shadows locals and fails cppcheck by @weebl2000 in https://github.com/meshtastic/firmware/pull/9743\r\n- Add Transmit history persistence for respecting traffic intervals between reboots by @thebentern in https://github.com/meshtastic/firmware/pull/9748\r\n- Add new configuration files for LR11xx variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/9761\r\n- Unlock 0x8B5 register macro guard for SX162 by @thebentern in https://github.com/meshtastic/firmware/pull/9777\r\n- Enhancement(mesh): remove late packets from tx queue when full by @m1nl in https://github.com/meshtastic/firmware/pull/9779\r\n- Add json file rotation option by @jp-bennett in https://github.com/meshtastic/firmware/pull/9783\r\n- PPA: Remove Ubuntu 25.04, Add 26.04 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9789\r\n- Deb: Handle offline builds more gracefully by @vidplace7 in https://github.com/meshtastic/firmware/pull/9791\r\n- Remove \"x\" permission bits from some source files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9794\r\n- Add some lora parameter clamping logic to coalesce to defaults and enforce some bounds by @thebentern in https://github.com/meshtastic/firmware/pull/9808\r\n- Add back FEM LNA mode configuration for LoRa by @thebentern in https://github.com/meshtastic/firmware/pull/9809\r\n- More RAK6421 work by @jp-bennett in https://github.com/meshtastic/firmware/pull/9813\r\n- Add ROUTER_LATE and TAK_TRACKER to congestion scaling exemption by @h3lix1 in https://github.com/meshtastic/firmware/pull/9818\r\n- Add ROUTER_LATE to telemetry impolite role check by @h3lix1 in https://github.com/meshtastic/firmware/pull/9819\r\n- Add ROUTER_LATE to infrastructure init and config preservation by @h3lix1 in https://github.com/meshtastic/firmware/pull/9820\r\n- Update Heltec Tracker v2 to version KCT8103L. by @Quency-D in https://github.com/meshtastic/firmware/pull/9822\r\n- Align 920–925 MHz limits as per NBTC regulations in Thailand (27 dBm, 10% duty cycle) by @hereismeaw in https://github.com/meshtastic/firmware/pull/9827\r\n- Add APIPort to native config by @pdxlocations in https://github.com/meshtastic/firmware/pull/9840\r\n- T-mini Eink S3 Support for both InkHUD and BaseUI by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9856\r\n- Consolidate SHTs into one class by @oscgonfer in https://github.com/meshtastic/firmware/pull/9859\r\n- Experiment: C++17 support by @thebentern in https://github.com/meshtastic/firmware/pull/9874\r\n- Remove a bunch of warnings in SEN5X by @oscgonfer in https://github.com/meshtastic/firmware/pull/9884\r\n- BaseUI: Emote Refactoring by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9896\r\n- Add spoof detection for UDP packets in UdpMulticastHandler by @NomDeTom in https://github.com/meshtastic/firmware/pull/9905\r\n- Heltec v4.3: enable LNA by default by @weebl2000 in https://github.com/meshtastic/firmware/pull/9906\r\n- Heltec V4 + TFT expansion kit: rotated MUI by @mverch67 in https://github.com/meshtastic/firmware/pull/9938\r\n- HexDump: Add const to the buf parameter in hexDump. by @fw190d13 in https://github.com/meshtastic/firmware/pull/9944\r\n- Add meshtasticd config metadata by @vidplace7 in https://github.com/meshtastic/firmware/pull/10001\r\n- Exclude accelerometer on new MESHTASTIC_EXCLUDE_ACCELEROMETER flag by @thebentern in https://github.com/meshtastic/firmware/pull/10004\r\n- MUI: WiFi map tile download: heltec V4 adaptations by @mverch67 in https://github.com/meshtastic/firmware/pull/10011\r\n- Mesh-tab wifi map + exclude screen fix by @mverch67 in https://github.com/meshtastic/firmware/pull/10038\r\n- Thinknode_m5 minor fixes by @jp-bennett in https://github.com/meshtastic/firmware/pull/10049\r\n- Add a hardfault handler so it's more obvious when STM32 crashes. by @Stary2001 in https://github.com/meshtastic/firmware/pull/10071\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Add agc reset attempt by @jp-bennett in https://github.com/meshtastic/firmware/pull/8163\r\n- Improved manual build flow to make it easier by @NomDeTom in https://github.com/meshtastic/firmware/pull/8839\r\n- Support mini ePaper S3 Kit by @mverch67 in https://github.com/meshtastic/firmware/pull/9335\r\n- Remove GPS Baudrate locking for Seeed Xiao S3 Kit by @fifieldt in https://github.com/meshtastic/firmware/pull/9374\r\n- Fix heltec v4 tft dependency by @Quency-D in https://github.com/meshtastic/firmware/pull/9507\r\n- Apply SX1262 register 0x8B5 patch for improved GC1109 RX sensitivity by @weebl2000 in https://github.com/meshtastic/firmware/pull/9571\r\n- Hold GC1109 FEM power during deep sleep for LNA RX wake by @weebl2000 in https://github.com/meshtastic/firmware/pull/9572\r\n- Fix some random compiler warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9596\r\n- Add missing openocd_target to custom nrf52 boards by @Stary2001 in https://github.com/meshtastic/firmware/pull/9603\r\n- Fixes on SCD4X admin comands by @oscgonfer in https://github.com/meshtastic/firmware/pull/9607\r\n- Feat/add scd30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9609\r\n- Zero entire public key array instead of only first byte by @weebl2000 in https://github.com/meshtastic/firmware/pull/9619\r\n- Respect DontMqttMeBro flag regardless of channel PSK by @weebl2000 in https://github.com/meshtastic/firmware/pull/9626\r\n- Undefine LED_BUILTIN for Heltec v2 variant by @ericbarch in https://github.com/meshtastic/firmware/pull/9647\r\n- Fix typo in PIN_GPS_SWITCH by @Jorropo in https://github.com/meshtastic/firmware/pull/9648\r\n- Workaround NCP5623 and LP5562 I2C builds by @Jorropo in https://github.com/meshtastic/firmware/pull/9652\r\n- RadioLib edge-triggered interrupts robustness by @compumike in https://github.com/meshtastic/firmware/pull/9658\r\n- Add USB_MODE=1 for Station G2 - Solving all my serial issues. by @h3lix1 in https://github.com/meshtastic/firmware/pull/9660\r\n- Fix detection of SCD30 by checking if the size of the return from a 2 byte register read is correct by @caveman99 in https://github.com/meshtastic/firmware/pull/9664\r\n- Fix/rak3401 button by @LN4CY in https://github.com/meshtastic/firmware/pull/9668\r\n- Undefine LED_BUILTIN for 9m2ibr_aprs_lora_tracker by @mrekin in https://github.com/meshtastic/firmware/pull/9685\r\n- BLE Pairing fix by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9701\r\n- Implement 'agc' reset for SX126x & LR11x0 chip families by @weebl2000 in https://github.com/meshtastic/firmware/pull/9705\r\n- Add explicit dependency on mklittlefs. by @cpatulea in https://github.com/meshtastic/firmware/pull/9708\r\n- Platform: nrf52: Fix typo in BLEDfuSecure filename by @KokoSoft in https://github.com/meshtastic/firmware/pull/9709\r\n- Meshtasticd: Add Luckfox Lyra Hat pinmaps by @vidplace7 in https://github.com/meshtastic/firmware/pull/9730\r\n- Fix WisMesh Tap V2 env mess by @thebentern in https://github.com/meshtastic/firmware/pull/9734\r\n- Hopefully fix remaining cppcheck issues by @caveman99 in https://github.com/meshtastic/firmware/pull/9745\r\n- Add heltec-v4.3 board by @Quency-D in https://github.com/meshtastic/firmware/pull/9753\r\n- Fix RAK4631 Ethernet gateway API connection loss after W5100S brownout by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9754\r\n- Fix Bluetooth on RAK Ethernet Gateway by removing MESHTASTIC_EXCLUDE_… by @thebentern in https://github.com/meshtastic/firmware/pull/9755\r\n- Increase PSRAM malloc threshold from 256 bytes to 2048 bytes by @thebentern in https://github.com/meshtastic/firmware/pull/9758\r\n- Don't launch canned message when waking screen or silencing notification by @jp-bennett in https://github.com/meshtastic/firmware/pull/9762\r\n- Fix nRF52 AsyncUDP multicast TX/RX race on W5100S by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9765\r\n- Fix W5100S socket exhaustion blocking MQTT and additional TCP clients by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9770\r\n- Avoid memory leak when possibly malformed packet is received by @m1nl in https://github.com/meshtastic/firmware/pull/9781\r\n- Add ADS1115 ADC to recognition as used on RAK6421 Hat by @caveman99 in https://github.com/meshtastic/firmware/pull/9790\r\n- Traceroute through MQTT misses uplink node if MQTT is encrypted by @domusonline in https://github.com/meshtastic/firmware/pull/9798\r\n- Improve resource cleanup on connection close (and make server API a unique pointer) by @thebentern in https://github.com/meshtastic/firmware/pull/9799\r\n- Spelling fixes by @ldoolitt in https://github.com/meshtastic/firmware/pull/9801\r\n- Spelling fixes in .md files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9810\r\n- Treat ROUTER_LATE like ROUTER for power management and defaults by @h3lix1 in https://github.com/meshtastic/firmware/pull/9815\r\n- Add ROUTER_LATE use the same rebroadcast rules as ROUTER by @h3lix1 in https://github.com/meshtastic/firmware/pull/9816\r\n- Prevent router-like roles from auto-favoriting DM peers by @h3lix1 in https://github.com/meshtastic/firmware/pull/9821\r\n- Fix(t1000e): reclassify P0.04 as sensor power enable GPIO by @weebl2000 in https://github.com/meshtastic/firmware/pull/9826\r\n- Don't double-blink Thinknode-M1 Power LED while charging by @jp-bennett in https://github.com/meshtastic/firmware/pull/9829\r\n- Debian: Extend sourcedeb cache expiration by @vidplace7 in https://github.com/meshtastic/firmware/pull/9858\r\n- Fix(tlora-pager): Remove SDCARD_USE_SPI1 so SX1262 and SD can share bus by @ndoo in https://github.com/meshtastic/firmware/pull/9870\r\n- Update ESP8266Audio dependency to Meshtastic fork for compatibility by @thebentern in https://github.com/meshtastic/firmware/pull/9872\r\n- Pioarduino Heltec v4: fix build due to LED_BUILTIN compile error. by @cpatulea in https://github.com/meshtastic/firmware/pull/9875\r\n- Fix rak_wismeshtag low‑voltage reboot hang after App configuration by @Ethan-chen1234-zy in https://github.com/meshtastic/firmware/pull/9897\r\n- Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio. by @niklaswall in https://github.com/meshtastic/firmware/pull/9916\r\n- Add new RAK 13302 power curve by @jp-bennett in https://github.com/meshtastic/firmware/pull/9929\r\n- MQTT settings silently fail to persist when broker is unreachable by @rcatal01 in https://github.com/meshtastic/firmware/pull/9934\r\n- Remove early return during scan of BME address for BMP sensors by @NomDeTom in https://github.com/meshtastic/firmware/pull/9935\r\n- Ensure infrastructure role-based minimums are coerced since they don't have scaling by @thebentern in https://github.com/meshtastic/firmware/pull/9937\r\n- Fixes #9792 : Hop with Meshtastic ffff and ?dB is added to missing hop in traceroute by @domusonline in https://github.com/meshtastic/firmware/pull/9945\r\n- Fix NodeInfo suppression logic to ensure suppression only applies to external requests by @thebentern in https://github.com/meshtastic/firmware/pull/9947\r\n- Enable touch-to-backlight on T-Echo (not just T-Echo Plus) by @okturan in https://github.com/meshtastic/firmware/pull/9953\r\n- Fix TFTDisplay::display to align pixels at 32-bit boundary by @notmasteryet in https://github.com/meshtastic/firmware/pull/9956\r\n- Fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users by @NomDeTom in https://github.com/meshtastic/firmware/pull/9958\r\n- Add heltec_mesh_node_t096 board. by @Quency-D in https://github.com/meshtastic/firmware/pull/9960\r\n- Cardputer-Adv I2S sound by @mverch67 in https://github.com/meshtastic/firmware/pull/9963\r\n- Fixes #9850: Double space issue with Cyrillic OLED font by @dev-nightcore in https://github.com/meshtastic/firmware/pull/9971\r\n- Add LED_BUILTIN for variant tlora_v1 by @RobertSasak in https://github.com/meshtastic/firmware/pull/9973\r\n- Add timeout to PPA uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/9989\r\n- Exclude web server, paxcounter and few others from original ESP32 generation to fix IRAM overflow by @thebentern in https://github.com/meshtastic/firmware/pull/10005\r\n- Update External Notifications with a full redo of logic gates by @Xaositek in https://github.com/meshtastic/firmware/pull/10006\r\n- Supporting STM32WL is like squeezing blood from a stone by @Stary2001 in https://github.com/meshtastic/firmware/pull/10015\r\n- Configure NFC pins as GPIO for older bootloaders by @NomDeTom in https://github.com/meshtastic/firmware/pull/10016\r\n- Fix TransmitHistory to improve epoch handling by @thebentern in https://github.com/meshtastic/firmware/pull/10017\r\n- Wio-sdk-wm1110: inherit build_unflags by @vidplace7 in https://github.com/meshtastic/firmware/pull/10034\r\n- ESP32: Take away \"tbeam\" boards PSRAM to reclaim iram by @vidplace7 in https://github.com/meshtastic/firmware/pull/10036\r\n- Set t5s3_epaper_inkhud to `extra` by @vidplace7 in https://github.com/meshtastic/firmware/pull/10037\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update adafruit mpu6050 to v2.2.9 by @app/renovate in https://github.com/meshtastic/firmware/pull/9611\r\n- Update Sensirion Core to v0.7.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9613\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9616\r\n- Update actions/stale action to v10.2.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9669\r\n- Update meshtastic-GxEPD2 digest to c7eb4c3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9694\r\n- Update radiolib to v7.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9695\r\n- Update sensorlib to v0.3.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9727\r\n- Update meshtastic-st7789 digest to 9ee76d6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9729\r\n- Update adafruit mlx90614 to v2.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9756\r\n- Update adafruit_tsl2561 to v1.1.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9757\r\n- Update platformio/espressif32 to v6.13.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9759\r\n- Update platformio/nordicnrf52 to v10.11.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9760\r\n- Update adafruit dps310 to v1.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9763\r\n- Update platformio/ststm32 to v19.5.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9764\r\n- Update adafruit ahtx0 to v2.0.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9766\r\n- Update github artifact actions (major) by @app/renovate in https://github.com/meshtastic/firmware/pull/9767\r\n- Update crazy-max/ghaction-import-gpg action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9787\r\n- Update arduinojson to v6.21.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9788\r\n- Update dorny/test-reporter action to v2.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9796\r\n- Update docker/login-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9806\r\n- Update docker/setup-qemu-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9807\r\n- Update docker/setup-buildx-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9824\r\n- Update docker/build-push-action action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9832\r\n- Update docker/metadata-action action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9833\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9839\r\n- Update meshtastic-esp32_https_server digest to b78f12c by @app/renovate in https://github.com/meshtastic/firmware/pull/9851\r\n- Update meshtastic/device-ui digest to 622b034 by @app/renovate in https://github.com/meshtastic/firmware/pull/9864\r\n- Update GxEPD2 to v1.6.8 by @app/renovate in https://github.com/meshtastic/firmware/pull/9918\r\n- Update pnpm/action-setup action to v5 by @app/renovate in https://github.com/meshtastic/firmware/pull/9926\r\n- Update meshtastic/device-ui digest to f36d2a9 by @app/renovate in https://github.com/meshtastic/firmware/pull/9940\r\n- Update dorny/test-reporter action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9981\r\n- Deps: Cleanup LewisHe library references by @vidplace7 in https://github.com/meshtastic/firmware/pull/10007\r\n- Dependencies: Remove all fuzzy-matches, spot-add renovate by @vidplace7 in https://github.com/meshtastic/firmware/pull/10008\r\n- Update Adafruit_BME680 to v2.0.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10009\r\n- Update meshtastic/device-ui digest to 7b1485b by @app/renovate in https://github.com/meshtastic/firmware/pull/10023\r\n- Renovate: Don't update branches outside the schedule (daily) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10039\r\n- Update meshtastic/device-ui digest to 7b1485b by @app/renovate in https://github.com/meshtastic/firmware/pull/10044\r\n- Update meshtastic/device-ui digest to 1897dd1 by @app/renovate in https://github.com/meshtastic/firmware/pull/10050\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.21.1370b23" }, { "id": "v2.7.20.6658ec2", @@ -184,8 +177,22 @@ "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.8.ef9d0d7", "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.8.ef9d0d7/firmware-esp32-2.6.8.ef9d0d7.zip", "release_notes": "## 🚀 Enhancements\r\n* 20948 compass support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6707\r\n* Update XIAO_NRF_KIT RXEN Pin definition by @NomDeTom in https://github.com/meshtastic/firmware/pull/6717\r\n* Add client notification before role based power saving (sleep) by @thebentern in https://github.com/meshtastic/firmware/pull/6759\r\n* Actions: Fix end to end tests by @vidplace7 in https://github.com/meshtastic/firmware/pull/6776\r\n* Add clarifying note about AHT20 also being included with AHT10 library by @NomDeTom in https://github.com/meshtastic/firmware/pull/6787\r\n* Only send nodes on want_config of 69421 by @thebentern in https://github.com/meshtastic/firmware/pull/6792\r\n* Add contact admin message (for QR code) by @thebentern in https://github.com/meshtastic/firmware/pull/6806\r\n* Crowpanel 4.3, 5.0, 7.0 support by @caveman99 in https://github.com/meshtastic/firmware/pull/6611\r\n* MQTT userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6802\r\n* Unmessagable implementation and defaults by @thebentern in https://github.com/meshtastic/firmware/pull/6811\r\n* Added new map report opt-in for compliance and limit map report (and default) to one hour by @thebentern in https://github.com/meshtastic/firmware/pull/6813\r\n* chore(deps): update meshtastic/device-ui digest to 35576e1 by @renovate in https://github.com/meshtastic/firmware/pull/6747\r\n\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Renovate: fix device-ui match (tiny fix) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6748\r\n* Add some no-nonsense coercion for self-reporting node values by @thebentern in https://github.com/meshtastic/firmware/pull/6793\r\n* Device-install.sh: detect t-eth-elite as s3 device by @chri2 in https://github.com/meshtastic/firmware/pull/6767\r\n* Fixes BUG #6243 by @Richard3366 in https://github.com/meshtastic/firmware/pull/6781\r\n* Update Seeed Solar Node by @rcarteraz in https://github.com/meshtastic/firmware/pull/6763\r\n* Protect T-Echo's touch button against phantom presses in OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6735\r\n* Don't run `test-native` for event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6749\r\n* Fix EVENT_MODE on mqttless targets by @vidplace7 in https://github.com/meshtastic/firmware/pull/6750\r\n* Fix event templates (names, PSKs) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6753\r\n* Add suppport for Quectel L80 by @fifieldt in https://github.com/meshtastic/firmware/pull/6803\r\n\r\n## New Contributors\r\n* @chri2 made their first contribution in https://github.com/meshtastic/firmware/pull/6767\r\n* @Richard3366 made their first contribution in https://github.com/meshtastic/firmware/pull/6781\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.7.2d6181f...v2.6.8.ef9d0d7" + }, + { + "id": "v2.6.7.2d6181f", + "title": "Meshtastic Firmware 2.6.7.2d6181f Alpha", + "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.7.2d6181f", + "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.7.2d6181f/firmware-esp32-2.6.7.2d6181f.zip", + "release_notes": "## 🚀 Enhancements\r\n* Step one of Linux Sensor support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6673\r\n* PMSA003I: add support for driving SET pin low while not actively taking a telemetry reading by @vogon in https://github.com/meshtastic/firmware/pull/6569\r\n* UDP-multicast: bump platform-native to fix UDP read of unitialized memory bug by @Jorropo in https://github.com/meshtastic/firmware/pull/6686\r\n* UDP-multicast: remove the thread from the multicast thread API by @Jorropo in https://github.com/meshtastic/firmware/pull/6685\r\n* Rate limit waypoints and alerts and increase to allow every 10 seconds instead of 5 by @thebentern in https://github.com/meshtastic/firmware/pull/6699\r\n* Restore InkHUD to defaults on factory reset by @todd-herbert in https://github.com/meshtastic/firmware/pull/6637\r\n* MUI: native frame buffer support by @mverch67 in https://github.com/meshtastic/firmware/pull/6703\r\n* Add PA1010D GPS support by @fmckeogh in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Fix: native runs 100% CPU in tft_task_handler() when deviceScreen is null by @jp-bennett in https://github.com/meshtastic/firmware/pull/6695\r\n* Lock SPI bus while in use by InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/6719\r\n* Update template for event userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6720\r\n* Renovate: Add changelogs for device-ui, cleanup by @vidplace7 in https://github.com/meshtastic/firmware/pull/6733\r\n* Update Bosch BSEC2 to v1.8.2610, BME68x to v1.2.40408 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6727\r\n\r\n## New Contributors\r\n* @vogon made their first contribution in https://github.com/meshtastic/firmware/pull/6569\r\n* @fmckeogh made their first contribution in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.6.54c1423...v2.6.7.2d6181f" } ] }, - "pullRequests": [] + "pullRequests": [ + { + "id": "9999", + "title": "Use UDP as roof node <---> indoor nodes backchannel", + "page_url": "https://github.com/meshtastic/firmware/pull/9999", + "zip_url": "https://discord.com/invite/meshtastic" + } + ] } \ No newline at end of file diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 628865010..342b845dd 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -45,12 +45,11 @@ import androidx.lifecycle.lifecycleScope import co.touchlab.kermit.Logger import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory -import com.eygraber.uri.toKmpUri import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.android.inject +import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import org.meshtastic.app.intro.AnalyticsIntro import org.meshtastic.app.map.getMapViewProvider @@ -58,8 +57,8 @@ import org.meshtastic.app.node.component.InlineMap import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.ui.MainScreen import org.meshtastic.core.barcode.rememberBarcodeScanner +import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI -import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.nfc.NfcScannerEffect import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_invalid @@ -92,8 +91,6 @@ import org.meshtastic.feature.node.metrics.TracerouteMapScreen class MainActivity : ComponentActivity() { private val model: UIViewModel by viewModel() - private val usbRepository: UsbRepository by inject() - /** * Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers * itself as a LifecycleObserver in its init block. @@ -127,8 +124,6 @@ class MainActivity : ComponentActivity() { setSingletonImageLoaderFactory { get() } val theme by model.theme.collectAsStateWithLifecycle() - val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle() - val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) val dynamic = theme == MODE_DYNAMIC val dark = when (theme) { @@ -146,7 +141,7 @@ class MainActivity : ComponentActivity() { } AppCompositionLocals { - AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) { + AppTheme(dynamicColor = dynamic, darkTheme = dark) { val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle() // Signal to the system that the initial UI is "fully drawn" @@ -169,16 +164,6 @@ class MainActivity : ComponentActivity() { handleIntent(intent) } - override fun onResume() { - super.onResume() - // Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is - // resumed while a USB device is already attached (e.g. process restart, returning - // from another app), the manifest-declared attach intent may have already fired - // before UsbRepository was constructed. Re-poll deviceList here so the UI reflects - // reality without requiring the user to physically replug. - usbRepository.refreshState() - } - @Composable private fun AppCompositionLocals(content: @Composable () -> Unit) { CompositionLocalProvider( @@ -190,14 +175,8 @@ class MainActivity : ComponentActivity() { LocalMapViewProvider provides getMapViewProvider(), LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) }, LocalNodeTrackMapProvider provides - { destNum, positions, modifier, selectedPositionTime, onPositionSelected -> - org.meshtastic.app.map.node.NodeTrackMap( - destNum, - positions, - modifier, - selectedPositionTime, - onPositionSelected, - ) + { destNum, positions, modifier -> + org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier) }, LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(), LocalTracerouteMapProvider provides @@ -270,11 +249,6 @@ class MainActivity : ComponentActivity() { UsbManager.ACTION_USB_DEVICE_ATTACHED -> { Logger.d { "USB device attached" } - // Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared - // receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository - // never sees this event. Forward it explicitly so the serialDevices StateFlow - // refreshes and the device shows up in the Connect → Serial tab. - usbRepository.refreshState() showSettingsPage() } @@ -296,7 +270,7 @@ class MainActivity : ComponentActivity() { private fun handleMeshtasticUri(uri: Uri) { Logger.d { "Handling Meshtastic URI: $uri" } - model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } } + model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } } } private fun createShareIntent(message: String): PendingIntent { diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index 9228b6874..d32cc3df6 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -28,7 +28,6 @@ import androidx.work.WorkManager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first @@ -37,8 +36,9 @@ import kotlinx.coroutines.withTimeout import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.androidx.workmanager.koin.workManagerFactory -import org.koin.plugin.module.dsl.startKoin -import org.meshtastic.app.di.AndroidKoinApp +import org.koin.core.context.startKoin +import org.meshtastic.app.di.AppKoinModule +import org.meshtastic.app.di.module import org.meshtastic.core.common.ContextServices import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.repository.MeshPrefs @@ -57,15 +57,16 @@ open class MeshUtilApplication : Application(), Configuration.Provider { - private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val applicationScope = CoroutineScope(Dispatchers.Default) override fun onCreate() { super.onCreate() ContextServices.app = this - startKoin { + startKoin { androidContext(this@MeshUtilApplication) workManagerFactory() + modules(AppKoinModule().module()) } // Schedule periodic MeshLog cleanup diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt index 91ab81ec0..7f6fb0215 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt @@ -24,8 +24,6 @@ import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.disk.DiskCache import coil3.memory.MemoryCache -import coil3.memoryCacheMaxSizePercentWhileInBackground -import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder @@ -33,25 +31,18 @@ import coil3.util.DebugLogger import coil3.util.Logger import io.ktor.client.HttpClient import io.ktor.client.engine.android.Android -import io.ktor.client.plugins.DefaultRequest -import io.ktor.client.plugins.HttpRequestRetry -import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logging -import io.ktor.client.request.url import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import okio.Path.Companion.toOkioPath import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.network.HttpClientDefaults -import org.meshtastic.core.network.KermitHttpLogger private const val DISK_CACHE_PERCENT = 0.02 private const val MEMORY_CACHE_PERCENT = 0.25 -private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1 @Module class NetworkModule { @@ -72,12 +63,7 @@ class NetworkModule { buildConfigProvider: BuildConfigProvider, ): ImageLoader = ImageLoader.Builder(context = application) .components { - add( - KtorNetworkFetcherFactory( - httpClient = httpClient, - concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(), - ), - ) + add(KtorNetworkFetcherFactory(httpClient = httpClient)) add(SvgDecoder.Factory(scaleToDensity = true)) } .memoryCache { @@ -90,7 +76,6 @@ class NetworkModule { .build() } .logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null) - .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT) .crossfade(enable = true) .build() @@ -98,21 +83,8 @@ class NetworkModule { fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient = HttpClient(engineFactory = Android) { install(plugin = ContentNegotiation) { json(json) } - install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) } - install(plugin = HttpTimeout) { - requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - } - install(plugin = HttpRequestRetry) { - retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES) - exponentialDelay() - } if (buildConfigProvider.isDebug) { - install(plugin = Logging) { - logger = KermitHttpLogger - level = LogLevel.BODY - } + install(plugin = Logging) { level = LogLevel.BODY } } } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt similarity index 97% rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt rename to app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt index a8bce5529..997d7d08b 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.map.component +package org.meshtastic.app.map.component import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt similarity index 87% rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt rename to app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt index 431354e6d..74f08e07f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt @@ -14,15 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.map.component +package org.meshtastic.app.map.component import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FloatingToolbarDefaults -import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -43,9 +41,8 @@ import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.StatusColors.StatusRed /** - * Shared map controls overlay using [HorizontalFloatingToolbar] for Material 3 Expressive styling. Provides compass, - * filter button, location tracking button, and optional slots for flavor-specific content (map type selector, layers, - * refresh). + * Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location + * tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh). * * @param onToggleFilterMenu Callback to open/close the filter dropdown. * @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a @@ -57,7 +54,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed * @param isRefreshing Whether a refresh is currently in progress. * @param onRefresh Callback when the refresh button is clicked. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongParameterList") @Composable fun MapControlsOverlay( @@ -75,11 +71,7 @@ fun MapControlsOverlay( isRefreshing: Boolean = false, onRefresh: () -> Unit = {}, ) { - HorizontalFloatingToolbar( - expanded = true, - modifier = modifier, - colors = FloatingToolbarDefaults.standardFloatingToolbarColors(), - ) { + Row(modifier = modifier) { // Compass CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) diff --git a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt index 30e1b6be7..7b140cca8 100644 --- a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt @@ -25,7 +25,6 @@ import androidx.work.WorkerParameters import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import kotlinx.coroutines.CoroutineDispatcher -import org.koin.plugin.module.dsl.koinApplication import org.koin.test.verify.definition import org.koin.test.verify.injectedParameters import org.koin.test.verify.verify @@ -61,19 +60,4 @@ class KoinVerificationTest { ), ) } - - @Test - fun verifyTypedBootstrapLoadsModuleGraph() { - // koinApplication() is a K2 compiler plugin stub. If the plugin fails to - // transform it, the stub throws NotImplementedError at runtime. This test - // validates that the production bootstrap path is correctly transformed by - // successfully creating and closing the generated Koin application. - val app = koinApplication() - try { - // No-op: reaching this point proves the typed bootstrap path did not - // throw and the generated application could be created. - } finally { - app.close() - } - } } diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt index 37c19f477..8f262c47c 100644 --- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt +++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.app.service +import android.app.Notification import dev.mokkery.MockMode import dev.mokkery.mock import org.meshtastic.core.model.Node @@ -36,7 +37,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun updateServiceStateNotification( state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?, - ) {} + ): Notification = mock(MockMode.autofill) override suspend fun updateMessageNotification( contactKey: String, diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index de6062d33..0665d50db 100644 --- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -16,12 +16,12 @@ */ package org.meshtastic.app.ui -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import kotlinx.coroutines.flow.emptyFlow +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.meshtastic.core.navigation.NodesRoute @@ -35,14 +35,15 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -@OptIn(ExperimentalTestApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class NavigationAssemblyTest { + @get:Rule val composeTestRule = createComposeRule() + @Test - fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest { - setContent { + fun verifyNavigationGraphsAssembleWithoutCrashing() { + composeTestRule.setContent { val backStack = rememberNavBackStack(NodesRoute.NodesGraph) entryProvider { contactsGraph(backStack, emptyFlow()) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 71823c763..faaeb9f68 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.androidx.room.gradlePlugin) + compileOnly(libs.secrets.gradlePlugin) compileOnly(libs.spotless.gradlePlugin) compileOnly(libs.test.retry.gradlePlugin) diff --git a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt index 16166a776..046e3c4aa 100644 --- a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt @@ -18,7 +18,7 @@ import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.datadog.gradle.plugin.DdExtension import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask - +import com.datadog.gradle.plugin.InstrumentationMode import com.datadog.gradle.plugin.SdkCheckLevel import org.gradle.api.Plugin import org.gradle.api.Project @@ -110,7 +110,7 @@ class AnalyticsConventionPlugin : Plugin { variants { register(variant.name) { site = "US5" - + composeInstrumentation = InstrumentationMode.AUTO } } checkProjectDependencies = SdkCheckLevel.NONE diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 38cc021a7..fd432a1fa 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 2025 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,6 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + import com.android.build.api.dsl.ApplicationExtension import org.gradle.api.Plugin import org.gradle.api.Project @@ -25,6 +26,7 @@ import org.meshtastic.buildlogic.configureTestOptions class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { + apply(plugin = "com.android.application") apply(plugin = "org.gradle.test-retry") apply(plugin = "meshtastic.android.lint") @@ -36,8 +38,16 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } - defaultConfig { vectorDrawables.useSupportLibrary = true } + testOptions { + animationsDisabled = true + unitTests.isReturnDefaultValues = true + } buildTypes { getByName("release") { @@ -45,8 +55,7 @@ class AndroidApplicationConventionPlugin : Plugin { isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - rootProject.file("config/proguard/shared-rules.pro"), - "proguard-rules.pro", + "proguard-rules.pro" ) } getByName("debug") { @@ -58,7 +67,9 @@ class AndroidApplicationConventionPlugin : Plugin { } } - buildFeatures { buildConfig = true } + buildFeatures { + buildConfig = true + } } configureTestOptions() } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 68771d24a..cf3ae81db 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -38,6 +38,11 @@ class AndroidLibraryConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) + defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testOptions { + animationsDisabled = true + unitTests.isReturnDefaultValues = true + } defaultConfig { // When flavorless modules depend on flavored modules (like :core:data), diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index be280f29c..4d02a630a 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -42,7 +42,6 @@ class KmpFeatureConventionPlugin : Plugin { extensions.configure { sourceSets.getByName("commonMain").dependencies { // Compose Multiplatform UI - implementation(libs.library("compose-multiplatform-animation")) implementation(libs.library("compose-multiplatform-material3")) // Lifecycle & ViewModel (JetBrains KMP forks — safe in commonMain) @@ -54,18 +53,19 @@ class KmpFeatureConventionPlugin : Plugin { // Logging implementation(libs.library("kermit")) - - // @Preview available in commonMain since CMP 1.11 (androidx.compose.ui.tooling.preview.Preview) - // org.jetbrains.compose.ui.tooling.preview.Preview is deprecated in 1.11 - implementation(libs.library("compose-multiplatform-ui-tooling-preview")) } sourceSets.getByName("androidMain").dependencies { + // Compose BOM for consistent Android Compose versions + implementation(target.dependencies.platform(libs.library("androidx-compose-bom"))) + // Common Android Compose dependencies implementation(libs.library("accompanist-permissions")) implementation(libs.library("androidx-activity-compose")) + implementation(libs.library("androidx-compose-material3")) - implementation(libs.library("compose-multiplatform-ui")) + implementation(libs.library("androidx-compose-ui-text")) + implementation(libs.library("androidx-compose-ui-tooling-preview")) } sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) } diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt index 67b2c8fd0..2a9504221 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt @@ -32,12 +32,10 @@ class KmpLibraryComposeConventionPlugin : Plugin { apply(plugin = libs.plugin("compose-multiplatform").get().pluginId) extensions.configure { - 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")) - } + 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")) } } configureComposeCompiler() diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt index 540834ef5..a1a111a64 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -14,9 +14,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +import dev.mokkery.gradle.MokkeryGradleExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback import org.meshtastic.buildlogic.configureKmpTestDependencies import org.meshtastic.buildlogic.configureKotlinMultiplatform @@ -37,6 +39,8 @@ class KmpLibraryConventionPlugin : Plugin { apply(plugin = "org.gradle.test-retry") apply(plugin = libs.plugin("mokkery").get().pluginId) + extensions.configure { stubs.allowConcreteClassInstantiation.set(true) } + configureKotlinMultiplatform() configureKmpTestDependencies() configureTestOptions() diff --git a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt index b4f2acfbe..9b832ce16 100644 --- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt @@ -29,12 +29,11 @@ class KoinConventionPlugin : Plugin { // Configure Koin K2 Compiler Plugin (0.4.0+) extensions.configure(KoinGradleExtension::class.java) { - // Meshtastic uses dependency inversion across KMP modules — interfaces in - // commonMain, implementations wired at the composition root. Koin's compileSafety - // flag enables A1 per-module checks that treat every module as self-contained, - // which breaks this pattern. There is no separate flag for A3 full-graph - // validation. Until Koin exposes granular safety levels we keep this disabled; - // runtime graph verification is handled by KoinVerificationTest instead. + // Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1 + // per-module safety checks strictly enforce that all dependencies must be explicitly + // provided or included locally. This breaks decoupled Clean Architecture designs. + // We disable compile safety globally to properly rely on Koin's A3 full-graph + // validation which perfectly handles inverted dependencies at the composition root. compileSafety.set(false) } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt index b438fe6c6..40cbe83fa 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt @@ -24,46 +24,18 @@ import org.gradle.kotlin.dsl.dependencies internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) { commonExtension.apply { buildFeatures.compose = true } - // CMP is the sole Compose version authority (BOM removed from the catalog). - // Third-party libraries (maps-compose, datadog, etc.) carry a transitive - // compose-bom whose constraints conflict with CMP-published AndroidX artifacts. - // Exclude it globally so CMP's own dependency graph wins. - configurations.configureEach { - exclude(mapOf("group" to "androidx.compose", "module" to "compose-bom")) - } - - // CMP publishes these core AndroidX groups at the CMP version tag. - // Material, Material3, and Adaptive follow separate AndroidX version numbers - // and must NOT be included here (see CMP release notes for the mapping table). - val cmpVersion = libs.version("compose-multiplatform") - val cmpAlignedGroups = setOf( - "androidx.compose.animation", - "androidx.compose.foundation", - "androidx.compose.runtime", - "androidx.compose.ui", - ) - - // The BOM exclusion above strips versions from transitive material deps - // (e.g. maps-compose-widgets, datadog). Pin the material group to the - // AndroidX version that matches this CMP release. - val materialVersion = libs.version("androidx-compose-material") - - configurations.configureEach { - resolutionStrategy.eachDependency { - if (requested.group in cmpAlignedGroups) { - useVersion(cmpVersion) - } else if (requested.group == "androidx.compose.material") { - useVersion(materialVersion) - } - } - } - val hasAndroidTest = project.projectDir.resolve("src/androidTest").exists() dependencies { - "debugImplementation"(libs.library("compose-multiplatform-ui-tooling")) - "implementation"(libs.library("compose-multiplatform-runtime")) + val bom = libs.library("androidx-compose-bom") + "implementation"(platform(bom)) + if (hasAndroidTest) { + "androidTestImplementation"(platform(bom)) + } + "debugImplementation"(libs.library("androidx-compose-ui-tooling")) + "implementation"(libs.library("androidx-compose-runtime")) "runtimeOnly"(libs.library("androidx-compose-runtime-tracing")) + "implementation"(libs.library("compose-multiplatform-runtime")) "implementation"(libs.library("compose-multiplatform-resources")) // Add Espresso explicitly to avoid version mismatch issues on newer Android versions diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index 088ca0d25..580db4c4b 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -44,19 +44,19 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) { compileSdk = compileSdkVersion defaultConfig.minSdk = minSdkVersion - defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" if (this is ApplicationExtension) { defaultConfig.targetSdk = targetSdkVersion } - val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21 + val javaVersion = if (project.name in listOf("api", "model", "proto")) { + JavaVersion.VERSION_17 + } else { + JavaVersion.VERSION_21 + } compileOptions.sourceCompatibility = javaVersion compileOptions.targetCompatibility = javaVersion - testOptions.animationsDisabled = true - testOptions.unitTests.isReturnDefaultValues = true - // Exclude duplicate META-INF license files shipped by JUnit Platform JARs packaging.resources.excludes.addAll( listOf( @@ -72,23 +72,6 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) { /** Configure Kotlin Multiplatform options */ internal fun Project.configureKotlinMultiplatform() { - // Skiko is an internal CMP implementation detail; third-party KMP libraries - // (e.g. coil3) can carry an older skiko transitive requirement that Gradle - // upgrades to the CMP-bundled version, triggering a "Skiko dependencies' - // versions are incompatible" warning from CMP's compatibility checker. - // Force the version to match CMP so the checker sees a consistent graph. - // Pinned here rather than in the version catalog because this plugin is the - // only consumer — bump together with the compose-multiplatform version. - val skikoVersion = "0.144.5" - configurations.configureEach { - resolutionStrategy.eachDependency { - if (requested.group == "org.jetbrains.skiko") { - useVersion(skikoVersion) - because("Align Skiko with the version bundled by Compose Multiplatform") - } - } - } - extensions.configure { // Standard KMP targets for Meshtastic jvm() @@ -207,25 +190,11 @@ internal fun Project.configureKotlinJvm() { configureKotlin() } -/** Modules published for external consumers — use Java 17 for broader compatibility. */ -private val PUBLISHED_MODULES = setOf("api", "model", "proto") - -/** Compiler args shared across all Kotlin targets (JVM, Android, iOS, etc.). */ -private val SHARED_COMPILER_ARGS = listOf( - "-opt-in=kotlin.uuid.ExperimentalUuidApi", - "-opt-in=kotlin.time.ExperimentalTime", - "-Xexpect-actual-classes", - "-Xcontext-parameters", - "-Xannotation-default-target=param-property", - "-Xskip-prerelease-check", -) - /** Configure base Kotlin options */ private inline fun Project.configureKotlin() { - val isPublishedModule = project.name in PUBLISHED_MODULES - extensions.configure { - val javaVersion = if (isPublishedModule) 17 else 21 + val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21 + val isPublishedModule = project.name in listOf("api", "model", "proto") // Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older environments), // and Java 21 for the rest of the app. jvmToolchain(javaVersion) @@ -239,7 +208,14 @@ private inline fun Project.configureKotlin() { if (!isPublishedModule) { freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") } - freeCompilerArgs.addAll(SHARED_COMPILER_ARGS) + freeCompilerArgs.addAll( + "-opt-in=kotlin.uuid.ExperimentalUuidApi", + "-opt-in=kotlin.time.ExperimentalTime", + "-Xexpect-actual-classes", + "-Xcontext-parameters", + "-Xannotation-default-target=param-property", + "-Xskip-prerelease-check", + ) if (isJvmTarget) { freeCompilerArgs.add("-jvm-default=no-compatibility") } @@ -254,13 +230,21 @@ private inline fun Project.configureKotlin() { tasks.withType().configureEach { compilerOptions { + val isPublishedModule = project.name in listOf("api", "model", "proto") jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21) allWarningsAsErrors.set(warningsAsErrors) if (!isPublishedModule) { freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") } - freeCompilerArgs.addAll(SHARED_COMPILER_ARGS) - freeCompilerArgs.add("-jvm-default=no-compatibility") + freeCompilerArgs.addAll( + "-opt-in=kotlin.uuid.ExperimentalUuidApi", + "-opt-in=kotlin.time.ExperimentalTime", + "-Xexpect-actual-classes", + "-Xcontext-parameters", + "-Xannotation-default-target=param-property", + "-Xskip-prerelease-check", + "-jvm-default=no-compatibility", + ) } } } diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 91b8ebce2..2fa797c74 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -30,7 +30,7 @@ pluginManagement { } plugins { - id("com.gradle.develocity") version("4.4.1") + id("com.gradle.develocity") version("4.4.0") } dependencyResolutionManagement { diff --git a/codecov.yml b/codecov.yml index 7f77510ff..6e0989227 100644 --- a/codecov.yml +++ b/codecov.yml @@ -57,6 +57,10 @@ component_management: name: Desktop paths: - desktop/** + - component_id: example + name: Example + paths: + - mesh_service_example/** ignore: - "**/build/**" diff --git a/conductor/code_styleguides/general.md b/conductor/code_styleguides/general.md new file mode 100644 index 000000000..dfcc793f4 --- /dev/null +++ b/conductor/code_styleguides/general.md @@ -0,0 +1,23 @@ +# General Code Style Principles + +This document outlines general coding principles that apply across all languages and frameworks used in this project. + +## Readability +- Code should be easy to read and understand by humans. +- Avoid overly clever or obscure constructs. + +## Consistency +- Follow existing patterns in the codebase. +- Maintain consistent formatting, naming, and structure. + +## Simplicity +- Prefer simple solutions over complex ones. +- Break down complex problems into smaller, manageable parts. + +## Maintainability +- Write code that is easy to modify and extend. +- Minimize dependencies and coupling. + +## Documentation +- Document *why* something is done, not just *what*. +- Keep documentation up-to-date with code changes. diff --git a/conductor/index.md b/conductor/index.md new file mode 100644 index 000000000..3a362bc99 --- /dev/null +++ b/conductor/index.md @@ -0,0 +1,14 @@ +# Project Context + +## Definition +- [Product Definition](./product.md) +- [Product Guidelines](./product-guidelines.md) +- [Tech Stack](./tech-stack.md) + +## Workflow +- [Workflow](./workflow.md) +- [Code Style Guides](./code_styleguides/) + +## Management +- [Tracks Registry](./tracks.md) +- [Tracks Directory](./tracks/) \ No newline at end of file diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md new file mode 100644 index 000000000..b54944fea --- /dev/null +++ b/conductor/product-guidelines.md @@ -0,0 +1,19 @@ +# Product Guidelines + +## Brand Voice and Tone +- **Technical yet Accessible:** Communicate complex networking and hardware concepts clearly without being overly academic. +- **Reliable and Authoritative:** The app is a utility for critical, off-grid communication. Language should convey stability and safety. +- **Community-Oriented:** Encourage open-source participation and community support. + +## UX Principles +- **Offline-First:** Assume the user has no cellular or Wi-Fi connection. All core functions must work locally via the mesh network. +- **Adaptive Layouts:** Support multiple form factors seamlessly (phones, tablets, desktop) using Material 3 Adaptive Scaffold principles. +- **Information Density:** Give power users access to detailed metrics (SNR, battery, hop limits) without overwhelming beginners. Use progressive disclosure. + +## Prose Style +- **Clarity over cleverness:** Use plain English. +- **Action-oriented:** Button labels and prompts should start with strong verbs (e.g., "Send", "Connect", "Export"). +- **Consistent Terminology:** + - Use "Node" for devices on the network. + - Use "Channel" for communication groups. + - Use "Direct Message" for 1-to-1 communication. \ No newline at end of file diff --git a/conductor/product.md b/conductor/product.md new file mode 100644 index 000000000..edfac5083 --- /dev/null +++ b/conductor/product.md @@ -0,0 +1,26 @@ +# Initial Concept +A tool for using Android with open-source mesh radios. + +# Product Guide + +## Overview +Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facilitate communication over off-grid, decentralized mesh networks using open-source hardware radios. + +## Target Audience +- Off-grid communication enthusiasts and hobbyists +- Outdoor adventurers needing reliable communication without cellular networks +- Emergency response and disaster relief teams + +## Core Features +- Direct communication with Meshtastic hardware (via BLE, USB, TCP, MQTT) +- Decentralized text messaging across the mesh network +- Unified cross-platform notifications for messages and node events +- Adaptive node and contact management +- Offline map rendering and device positioning +- Device configuration and firmware updates +- Unified cross-platform debugging and packet inspection + +## Key Architecture Goals +- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS) +- Ensure offline-first functionality and resilient data persistence (Room 3 KMP) +- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform \ No newline at end of file diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md new file mode 100644 index 000000000..75237887b --- /dev/null +++ b/conductor/tech-stack.md @@ -0,0 +1,38 @@ +# Tech Stack + +## Programming Language +- **Kotlin Multiplatform (KMP):** The core logic is shared across Android, Desktop, and iOS using `commonMain`. + +## Frontend Frameworks +- **Compose Multiplatform:** Shared UI layer for rendering on Android and Desktop. +- **Jetpack Compose:** Used where platform-specific UI (like charts or permissions) is necessary on Android. + +## Background & Services +- **Platform Services:** Core service orchestrations and background work are abstracted into `core:service` to maximize logic reuse across targets, using platform-specific implementations (e.g., WorkManager/Service on Android) only where necessary. + +## Architecture +- **MVI / Unidirectional Data Flow:** Shared view models using the multiplatform `androidx.lifecycle.ViewModel`. +- **JetBrains Navigation 3:** Multiplatform fork for state-based, compose-first navigation without relying on `NavController`. Navigation graphs are decoupled and extracted into their respective `feature:*` modules, allowing a thinned out root `app` module. + +## Dependency Injection +- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt. + +## Database & Storage +- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and platform-appropriate SQLite drivers (e.g., `BundledSQLiteDriver` for JVM/Desktop, Framework driver for Android). +- **Jetpack DataStore:** Shared preferences. + +## Networking & Transport +- **Ktor:** Multiplatform HTTP client for web services and TCP streaming. +- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS). +- **jSerialComm:** Cross-platform Java library used for direct Serial/USB communication with Meshtastic devices on the Desktop (JVM) target. +- **KMQTT:** Kotlin Multiplatform MQTT client and broker used for MQTT transport, replacing the Android-only Paho library. +- **Coroutines & Flows:** For asynchronous programming and state management. + +## Testing (KMP) +- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`. +- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows. +- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`. +- **Platform-Specific Verification:** Use **Robolectric** on the Android host target to verify KMP modules that interact with Android framework components (like `Uri` or `Room`). +- **Subclassing Pattern:** Maintain a unified test suite by defining abstract base tests in `commonTest` and platform-specific subclasses in `jvmTest` and `androidHostTest` for initialization (e.g., calling `setupTestContext()`). +- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates. +- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios. \ No newline at end of file diff --git a/conductor/tracks.md b/conductor/tracks.md new file mode 100644 index 000000000..0b5c54e3d --- /dev/null +++ b/conductor/tracks.md @@ -0,0 +1,5 @@ +# Project Tracks + +This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder. + +--- diff --git a/conductor/workflow.md b/conductor/workflow.md new file mode 100644 index 000000000..6f9cfd8fc --- /dev/null +++ b/conductor/workflow.md @@ -0,0 +1,333 @@ +# Project Workflow + +## Guiding Principles + +1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md` +2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation +3. **Test-Driven Development:** Write unit tests before implementing functionality +4. **High Code Coverage:** Aim for >80% code coverage for all modules +5. **User Experience First:** Every decision should prioritize user experience +6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution. + +## Task Workflow + +All tasks follow a strict lifecycle: + +### Standard Task Workflow + +1. **Select Task:** Choose the next available task from `plan.md` in sequential order + +2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]` + +3. **Write Failing Tests (Red Phase):** + - Create a new test file for the feature or bug fix. + - Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task. + - **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests. + +4. **Implement to Pass Tests (Green Phase):** + - Write the minimum amount of application code necessary to make the failing tests pass. + - Run the test suite again and confirm that all tests now pass. This is the "Green" phase. + +5. **Refactor (Optional but Recommended):** + - With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior. + - Rerun tests to ensure they still pass after refactoring. + +6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like: + ```bash + pytest --cov=app --cov-report=html + ``` + Target: >80% coverage for new code. The specific tools and commands will vary by language and framework. + +7. **Document Deviations:** If implementation differs from tech stack: + - **STOP** implementation + - Update `tech-stack.md` with new design + - Add dated note explaining the change + - Resume implementation + +8. **Commit Code Changes:** + - Stage all code changes related to the task. + - Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`. + - Perform the commit. + +9. **Attach Task Summary with Git Notes:** + - **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`). + - **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change. + - **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit. + ```bash + # The note content from the previous step is passed via the -m flag. + git notes add -m "" + ``` + +10. **Get and Record Task Commit SHA:** + - **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash. + - **Step 10.2: Write Plan:** Write the updated content back to `plan.md`. + +11. **Commit Plan Update:** + - **Action:** Stage the modified `plan.md` file. + - **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`). + +### Phase Completion Verification and Checkpointing Protocol + +**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`. + +1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun. + +2. **Ensure Test Coverage for Phase Changes:** + - **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit. + - **Step 2.2: List Changed Files:** Execute `git diff --name-only HEAD` to get a precise list of all files modified during this phase. + - **Step 2.3: Verify and Create Tests:** For each file in the list: + - **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`). + - For each remaining code file, verify a corresponding test file exists. + - If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`). + +3. **Execute Automated Tests with Proactive Debugging:** + - Before execution, you **must** announce the exact shell command you will use to run the tests. + - **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`" + - Execute the announced command. + - If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance. + +4. **Propose a Detailed, Actionable Manual Verification Plan:** + - **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase. + - You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes. + - The plan you present to the user **must** follow this format: + + **For a Frontend Change:** + ``` + The automated tests have passed. For manual verification, please follow these steps: + + **Manual Verification Steps:** + 1. **Start the development server with the command:** `npm run dev` + 2. **Open your browser to:** `http://localhost:3000` + 3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly. + ``` + + **For a Backend Change:** + ``` + The automated tests have passed. For manual verification, please follow these steps: + + **Manual Verification Steps:** + 1. **Ensure the server is running.** + 2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'` + 3. **Confirm that you receive:** A JSON response with a status of `201 Created`. + ``` + +5. **Await Explicit User Feedback:** + - After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**" + - **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation. + +6. **Create Checkpoint Commit:** + - Stage all changes. If no changes occurred in this step, proceed with an empty commit. + - Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`). + +7. **Attach Auditable Verification Report using Git Notes:** + - **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation. + - **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit. + +8. **Get and Record Phase Checkpoint SHA:** + - **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`). + - **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: ]`. + - **Step 8.3: Write Plan:** Write the updated content back to `plan.md`. + +9. **Commit Plan Update:** + - **Action:** Stage the modified `plan.md` file. + - **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '' as complete`. + +10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note. + +### Quality Gates + +Before marking any task complete, verify: + +- [ ] All tests pass +- [ ] Code coverage meets requirements (>80%) +- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`) +- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc) +- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types) +- [ ] No linting or static analysis errors (using the project's configured tools) +- [ ] Works correctly on mobile (if applicable) +- [ ] Documentation updated if needed +- [ ] No security vulnerabilities introduced + +## Development Commands + +**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.** + +### Setup +```bash +# Example: Commands to set up the development environment (e.g., install dependencies, configure database) +# e.g., for a Node.js project: npm install +# e.g., for a Go project: go mod tidy +``` + +### Daily Development +```bash +# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format) +# e.g., for a Node.js project: npm run dev, npm test, npm run lint +# e.g., for a Go project: go run main.go, go test ./..., go fmt ./... +``` + +### Before Committing +```bash +# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests) +# e.g., for a Node.js project: npm run check +# e.g., for a Go project: make check (if a Makefile exists) +``` + +## Testing Requirements + +### Unit Testing +- Every module must have corresponding tests. +- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach). +- Mock external dependencies. +- Test both success and failure cases. + +### Integration Testing +- Test complete user flows +- Verify database transactions +- Test authentication and authorization +- Check form submissions + +### Mobile Testing +- Test on actual iPhone when possible +- Use Safari developer tools +- Test touch interactions +- Verify responsive layouts +- Check performance on 3G/4G + +## Code Review Process + +### Self-Review Checklist +Before requesting review: + +1. **Functionality** + - Feature works as specified + - Edge cases handled + - Error messages are user-friendly + +2. **Code Quality** + - Follows style guide + - DRY principle applied + - Clear variable/function names + - Appropriate comments + +3. **Testing** + - Unit tests comprehensive + - Integration tests pass + - Coverage adequate (>80%) + +4. **Security** + - No hardcoded secrets + - Input validation present + - SQL injection prevented + - XSS protection in place + +5. **Performance** + - Database queries optimized + - Images optimized + - Caching implemented where needed + +6. **Mobile Experience** + - Touch targets adequate (44x44px) + - Text readable without zooming + - Performance acceptable on mobile + - Interactions feel native + +## Commit Guidelines + +### Message Format +``` +(): + +[optional body] + +[optional footer] +``` + +### Types +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation only +- `style`: Formatting, missing semicolons, etc. +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `test`: Adding missing tests +- `chore`: Maintenance tasks + +### Examples +```bash +git commit -m "feat(auth): Add remember me functionality" +git commit -m "fix(posts): Correct excerpt generation for short posts" +git commit -m "test(comments): Add tests for emoji reaction limits" +git commit -m "style(mobile): Improve button touch targets" +``` + +## Definition of Done + +A task is complete when: + +1. All code implemented to specification +2. Unit tests written and passing +3. Code coverage meets project requirements +4. Documentation complete (if applicable) +5. Code passes all configured linting and static analysis checks +6. Works beautifully on mobile (if applicable) +7. Implementation notes added to `plan.md` +8. Changes committed with proper message +9. Git note with task summary attached to the commit + +## Emergency Procedures + +### Critical Bug in Production +1. Create hotfix branch from main +2. Write failing test for bug +3. Implement minimal fix +4. Test thoroughly including mobile +5. Deploy immediately +6. Document in plan.md + +### Data Loss +1. Stop all write operations +2. Restore from latest backup +3. Verify data integrity +4. Document incident +5. Update backup procedures + +### Security Breach +1. Rotate all secrets immediately +2. Review access logs +3. Patch vulnerability +4. Notify affected users (if any) +5. Document and update security procedures + +## Deployment Workflow + +### Pre-Deployment Checklist +- [ ] All tests passing +- [ ] Coverage >80% +- [ ] No linting errors +- [ ] Mobile testing complete +- [ ] Environment variables configured +- [ ] Database migrations ready +- [ ] Backup created + +### Deployment Steps +1. Merge feature branch to main +2. Tag release with version +3. Push to deployment service +4. Run database migrations +5. Verify deployment +6. Test critical paths +7. Monitor for errors + +### Post-Deployment +1. Monitor analytics +2. Check error logs +3. Gather user feedback +4. Plan next iteration + +## Continuous Improvement + +- Review workflow weekly +- Update based on pain points +- Document lessons learned +- Optimize for user happiness +- Keep things simple and maintainable diff --git a/config.properties b/config.properties index de820bc85..1bb8534cd 100644 --- a/config.properties +++ b/config.properties @@ -21,8 +21,8 @@ VERSION_CODE_OFFSET=29314197 # Application and SDK versions APPLICATION_ID=com.geeksville.mesh MIN_SDK=26 -TARGET_SDK=37 -COMPILE_SDK=37 +TARGET_SDK=36 +COMPILE_SDK=36 # Base version name for local development and fallback # On CI, this is overridden by the Git tag diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro deleted file mode 100644 index 8d0d8efde..000000000 --- a/config/proguard/shared-rules.pro +++ /dev/null @@ -1,166 +0,0 @@ -# ============================================================================ -# Meshtastic — Shared ProGuard / R8 rules -# ============================================================================ -# Cross-platform keep and dontwarn rules applied to BOTH the Android app -# release build (R8) and the Desktop distribution (ProGuard). Host-specific -# rules live in the per-module proguard-rules.pro file. -# -# Rule of thumb: anything describing a library shared between Android and -# Desktop (Koin, kotlinx-serialization, Wire, Room KMP, Ktor, Coil 3, Kable, -# Kermit, Okio, DataStore, Paging, Lifecycle / Navigation 3, AboutLibraries, -# Markdown renderer, QRCode, Compose Multiplatform resources, core modules) -# belongs here. Anything platform-specific (AWT/Skiko/JNA, AIDL, Android -# framework, JDK-version quirks, flavor specifics) stays in the host file. -# ============================================================================ - -# ---- Attributes ------------------------------------------------------------- - -# Preserve line numbers for meaningful stack traces, plus metadata needed for -# reflective serializer/DI/Room lookups. --keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations - -# ---- Kotlin / Coroutines ---------------------------------------------------- -# Kotlin stdlib and kotlinx-coroutines ship their own consumer ProGuard rules -# (kotlin-stdlib and kotlinx-coroutines-core consumer-rules.pro) which keep -# Metadata, Continuation, kotlin.reflect internals, and debug metadata. No -# explicit wildcards needed here. - -# ---- Koin DI (reflection-based injection) ----------------------------------- - -# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException -# replacing Koin's InstanceCreationException in stack traces, making crashes -# undiagnosable). Broadened to all of koin core to cover the KSP-generated graph. --keep class org.koin.** { *; } --dontwarn org.koin.** - -# Keep Koin-annotated modules/components so Koin Annotations (KSP) output -# survives tree-shaking. --keep @org.koin.core.annotation.Module class * { *; } --keep @org.koin.core.annotation.ComponentScan class * { *; } --keep @org.koin.core.annotation.Single class * { *; } --keep @org.koin.core.annotation.Factory class * { *; } --keep @org.koin.core.annotation.KoinViewModel class * { *; } - -# ---- kotlinx-serialization -------------------------------------------------- - --keep class kotlinx.serialization.** { *; } --dontwarn kotlinx.serialization.** - -# Keep @Serializable classes and their generated $serializer companions --keepclassmembers @kotlinx.serialization.Serializable class ** { - static ** Companion; - kotlinx.serialization.KSerializer serializer(...); -} --keep class **.$serializer { *; } --keepclassmembers class **.$serializer { *; } --keepclasseswithmembers class ** { - kotlinx.serialization.KSerializer serializer(...); -} - -# ---- Wire Protobuf ---------------------------------------------------------- - -# Wire generates an ADAPTER static field on every Message subclass accessed -# reflectively during encoding/decoding. Keep those fields and the -# ProtoAdapter subclasses themselves; Wire's bundled consumer rules preserve -# the runtime itself. --keepclassmembers class * extends com.squareup.wire.Message { - public static *** ADAPTER; -} --keepclassmembers class * extends com.squareup.wire.ProtoAdapter { *; } - -# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs -# when compiling for non-Android JVM targets; harmless on Android). --dontwarn android.os.Parcel** --dontwarn android.os.Parcelable** - -# ---- Room KMP (room3) ------------------------------------------------------- - -# Preserve generated database constructors (Room uses reflection to instantiate) --keep class * extends androidx.room3.RoomDatabase { (); } --keep class * implements androidx.room3.RoomDatabaseConstructor { *; } - -# Keep the expect/actual MeshtasticDatabaseConstructor + database surface --keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } --keep class org.meshtastic.core.database.MeshtasticDatabase { *; } - -# Room's own consumer rules (from androidx.room3) keep DAOs, entities, -# generated _Impl classes, and TypeConverters referenced from the database. - -# ---- SQLite bundled -------------------------------------------------------- -# androidx.sqlite ships consumer rules. - -# ---- Ktor (ServiceLoader + plugin discovery) -------------------------------- - -# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory -# implementations reflectively via ServiceLoader). --keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } - -# ---- Coil 3 (image loading) ------------------------------------------------- -# coil3 ships consumer rules. - -# ---- Kable BLE -------------------------------------------------------------- -# com.juul.kable ships consumer rules; if release builds fail with missing -# Kable classes, restore a narrow keep for the specific reflection-loaded type. - -# ---- Compose Multiplatform resources ---------------------------------------- - -# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.). -# Without these the fdroid flavor has crashed at startup with a misleading -# URLDecodeException due to R8 exception-class merging. --keep class org.jetbrains.compose.resources.** { *; } --keep class org.meshtastic.core.resources.Res { *; } --keepclassmembers class org.meshtastic.core.resources.Res$* { *; } - -# ---- AboutLibraries --------------------------------------------------------- -# com.mikepenz.aboutlibraries ships consumer rules. - -# ---- Multiplatform Markdown Renderer ---------------------------------------- -# com.mikepenz.markdown ships consumer rules. - -# ---- QR Code Kotlin --------------------------------------------------------- - --keep class io.github.g0dkar.qrcode.** { *; } --dontwarn io.github.g0dkar.qrcode.** --keep class qrcode.** { *; } --dontwarn qrcode.** - -# ---- Kermit logging --------------------------------------------------------- -# co.touchlab.kermit ships consumer rules. - -# ---- Okio ------------------------------------------------------------------- -# okio ships consumer rules. - -# ---- DataStore -------------------------------------------------------------- -# androidx.datastore ships consumer rules. - -# ---- Paging ----------------------------------------------------------------- -# androidx.paging ships consumer rules. - -# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) ----------------- -# androidx.lifecycle and androidx.navigation3 ship consumer rules. - -# ---- Meshtastic shared model ------------------------------------------------ -# core.model types are reached via static references from Koin-wired graphs, -# Room entities, and kotlinx-serialization @Serializable companions — all of -# which have their own keep rules above. - -# ---- Compose Runtime & Animation -------------------------------------------- - -# Defence-in-depth: prevent tree-shaking of Compose infrastructure classes that -# are referenced indirectly through compiler-generated state machines. Applies -# to BOTH R8 (Android app) and ProGuard (desktop distribution). -# -# Why shared: CMP 1.11 ships consumer rules with -assumenosideeffects on -# Composer.() / ComposerImpl.() and -assumevalues on -# ComposeRuntimeFlags / ComposeStackTraceMode. If the optimizer runs (R8 full -# mode on Android, ProGuard with optimize.set(true) on desktop) these call -# sites can be rewritten even when the target classes are kept, causing the -# recomposer / frame-clock / animation state machines to silently freeze on -# the first frame. -dontoptimize (set per-host) is the primary defence; these -# keep rules are a safety net against future toolchain changes. See #5146. --keep class androidx.compose.runtime.** { *; } --keep class androidx.compose.ui.** { *; } --keep class androidx.compose.animation.core.** { *; } --keep class androidx.compose.animation.** { *; } --keep class androidx.compose.foundation.** { *; } --keep class androidx.compose.material3.** { *; } diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts index 711cccc09..c2533dd3c 100644 --- a/core/barcode/build.gradle.kts +++ b/core/barcode/build.gradle.kts @@ -33,9 +33,9 @@ dependencies { implementation(projects.core.ui) implementation(libs.androidx.activity.compose) - implementation(libs.compose.multiplatform.material3) - implementation(libs.compose.multiplatform.runtime) - implementation(libs.compose.multiplatform.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui) implementation(libs.accompanist.permissions) implementation(libs.kermit) @@ -52,6 +52,6 @@ dependencies { testImplementation(libs.junit) testRuntimeOnly(libs.junit.vintage.engine) testImplementation(libs.robolectric) - testImplementation(libs.compose.multiplatform.ui.test) + testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt index aa222b7c2..e06562cfb 100644 --- a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt +++ b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt @@ -16,17 +16,21 @@ */ package org.meshtastic.core.barcode -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.junit4.v2.createComposeRule +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -@OptIn(ExperimentalTestApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class BarcodeScannerTest { - @Test fun testRememberBarcodeScanner() = runComposeUiTest { setContent { rememberBarcodeScanner { _ -> } } } + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun testRememberBarcodeScanner() { + composeTestRule.setContent { rememberBarcodeScanner { _ -> } } + } } diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts index f270e6aa3..b61fad0e7 100644 --- a/core/ble/build.gradle.kts +++ b/core/ble/build.gradle.kts @@ -46,9 +46,13 @@ kotlin { implementation(libs.jetbrains.lifecycle.runtime) } - commonTest.dependencies { - implementation(libs.kotlinx.coroutines.test) - implementation(projects.core.testing) + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.androidx.lifecycle.testing) + } } } } diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt index b330453e1..c8d444688 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers @@ -50,7 +49,7 @@ class AndroidBluetoothRepository( private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions())) override val state: StateFlow = _state.asStateFlow() - private val deviceCache = mutableMapOf() + private val deviceCache = mutableMapOf() init { processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() } @@ -87,7 +86,7 @@ class AndroidBluetoothRepository( return } - suspendCancellableCoroutine { cont -> + kotlinx.coroutines.suspendCancellableCoroutine { cont -> val receiver = object : android.content.BroadcastReceiver() { @SuppressLint("MissingPermission") @@ -181,15 +180,14 @@ class AndroidBluetoothRepository( // user renamed the device in firmware since the cache was populated. deviceCache.keys.retainAll(bondedAddresses) return bonded.map { device -> - val cached = deviceCache.getOrPut(device.address) { MeshtasticBleDevice(device.address, device.name) } - // If the name changed (firmware rename, etc.), replace the cached entry and return the new one. - if (cached.name != device.name) { - val updated = MeshtasticBleDevice(device.address, device.name) - deviceCache[device.address] = updated - updated - } else { - cached - } + deviceCache + .getOrPut(device.address) { DirectBleDevice(device.address, device.name) } + .also { cached -> + // Refresh name if it changed (firmware rename, etc.) + if (cached.name != device.name) { + deviceCache[device.address] = DirectBleDevice(device.address, device.name) + } + } } } diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index b0617635a..e9928f8d5 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -20,29 +20,15 @@ import co.touchlab.kermit.Logger import com.juul.kable.AndroidPeripheral import com.juul.kable.Peripheral import com.juul.kable.PeripheralBuilder -import com.juul.kable.PooledThreadingStrategy import com.juul.kable.toIdentifier -/** - * Shared thread pool for Kable BLE connections. - * - * [PooledThreadingStrategy] reuses handler threads across reconnect cycles, avoiding the overhead of creating a new - * thread per connection attempt that [OnDemandThreadingStrategy][com.juul.kable.OnDemandThreadingStrategy] incurs. Idle - * threads are evicted after 1 minute (default). - * - * A single app-wide instance is used because Kable recommends exactly one pool per application. - */ -private val sharedThreadingStrategy = PooledThreadingStrategy() - internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) { - // Bonded devices without a fresh advertisement must use autoConnect = true. Otherwise, - // Android's direct connect algorithm often fails with GATT 133 or times out, especially - // if the device uses random resolvable addresses. Scanned devices (advertisement != null) - // use direct connection (autoConnect = false) for faster initial connects. + // If we're connecting blindly to a bonded device without a fresh scan (DirectBleDevice), + // we MUST use autoConnect = true. Otherwise, Android's direct connect algorithm will often fail + // immediately with GATT 133 or timeout, especially if the device uses random resolvable addresses. + // If we just scanned the device (KableBleDevice), direct connection (autoConnect = false) is faster. autoConnectIf(autoConnect) - threadingStrategy = sharedThreadingStrategy - onServicesDiscovered { try { // Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes. diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt index 1ea11622d..1bfaff648 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt @@ -19,17 +19,14 @@ package org.meshtastic.core.ble import com.juul.kable.Peripheral import kotlin.concurrent.Volatile -/** Snapshot of the currently active BLE peripheral and its address, updated atomically. */ -internal data class ActiveConnection(val peripheral: Peripheral, val address: String) - /** * A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between * dynamically created UI devices (scanned vs bonded) and the actual connection. * - * [active] is a single volatile reference so readers always see a consistent peripheral/address pair — the previous - * two-field design (`activePeripheral` + `activeAddress`) was susceptible to TOCTOU races when fields were updated - * non-atomically. + * Fields are volatile to ensure visibility across AIDL binder threads and coroutine dispatchers. */ internal object ActiveBleConnection { - @Volatile var active: ActiveConnection? = null + @Volatile var activePeripheral: Peripheral? = null + + @Volatile var activeAddress: String? = null } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt index 59cf134de..06496aeea 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.ble import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.onStart import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @@ -50,8 +49,8 @@ interface BleConnection { /** Connects to the given [BleDevice]. */ suspend fun connect(device: BleDevice) - /** Connects to the given [BleDevice] and waits for a terminal state or [timeout]. */ - suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState + /** Connects to the given [BleDevice] and waits for a terminal state. */ + suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState /** Disconnects from the current device. */ suspend fun disconnect() @@ -78,17 +77,6 @@ interface BleService { /** Observes notifications/indications from the characteristic. */ fun observe(characteristic: BleCharacteristic): Flow - /** - * Observes notifications/indications from the characteristic with an [onSubscription] action that fires **after** - * notifications are enabled (CCCD written). - * - * The [onSubscription] is re-invoked on every reconnect while the returned [Flow] is active. The default - * implementation invokes [onSubscription] eagerly on flow start so non-Kable implementations still signal - * readiness. - */ - fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit): Flow = - observe(characteristic).onStart { onSubscription() } - /** Reads the characteristic value once. */ suspend fun read(characteristic: BleCharacteristic): ByteArray diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt index 2026b0cb1..a9f82c5f9 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt @@ -17,53 +17,16 @@ package org.meshtastic.core.ble /** Represents the state of a BLE connection. */ -sealed interface BleConnectionState { - - /** - * The peripheral is disconnected. - * - * @param reason why the disconnect occurred. [DisconnectReason.Unknown] when the platform doesn't provide status - * information (e.g. JavaScript) or when the disconnect was synthesised locally without a GATT callback. - */ - data class Disconnected(val reason: DisconnectReason = DisconnectReason.Unknown) : BleConnectionState +sealed class BleConnectionState { + /** The peripheral is disconnected. */ + object Disconnected : BleConnectionState() /** The peripheral is connecting. */ - data object Connecting : BleConnectionState + object Connecting : BleConnectionState() /** The peripheral is connected. */ - data object Connected : BleConnectionState + object Connected : BleConnectionState() /** The peripheral is disconnecting. */ - data object Disconnecting : BleConnectionState -} - -/** - * Platform-agnostic reason for a BLE disconnect. - * - * Mapped from Kable's [com.juul.kable.State.Disconnected.Status] in `KableStateMapping`. - */ -sealed interface DisconnectReason { - /** Cause is unknown or the platform did not report one. */ - data object Unknown : DisconnectReason - - /** The local app/central initiated the disconnect. */ - data object LocalDisconnect : DisconnectReason - - /** The remote peripheral (firmware) initiated the disconnect. */ - data object RemoteDisconnect : DisconnectReason - - /** A connection attempt failed to establish. */ - data object ConnectionFailed : DisconnectReason - - /** The BLE link supervision timed out (device went out of range). */ - data object Timeout : DisconnectReason - - /** The connection was explicitly cancelled. */ - data object Cancelled : DisconnectReason - - /** An encryption or authentication failure occurred. */ - data object EncryptionFailed : DisconnectReason - - /** Platform-specific status code that doesn't map to a known reason. */ - data class PlatformSpecific(val code: Int) : DisconnectReason + object Disconnecting : BleConnectionState() } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt deleted file mode 100644 index d273a0b90..000000000 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("MatchingDeclarationName") // File groups the classifier function and its result type. - -package org.meshtastic.core.ble - -import com.juul.kable.GattRequestRejectedException -import com.juul.kable.GattStatusException -import com.juul.kable.NotConnectedException -import com.juul.kable.UnmetRequirementException - -/** - * Classification of a BLE-layer exception for the transport layer to act on. - * - * @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device. - * Currently always `false` — all known BLE exceptions can resolve without user intervention (BT toggling, permission - * grants, transient GATT errors). Reserved for future use. - * @property gattStatus the platform GATT status code when available (Android-specific). - * @property message a human-readable description of the failure. - */ -data class BleExceptionInfo(val isPermanent: Boolean, val gattStatus: Int? = null, val message: String) - -/** - * Inspects this [Throwable] and returns a [BleExceptionInfo] if it is a known Kable exception, or `null` if it is - * unrelated to the BLE layer. - * - * This keeps Kable type knowledge inside `core:ble` so that `core:network` (and other consumers) can classify BLE - * exceptions without depending on Kable directly. - */ -fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) { - is GattStatusException -> - BleExceptionInfo( - isPermanent = false, - gattStatus = status, - message = "GATT error (status $status): $message", - ) - is NotConnectedException -> BleExceptionInfo(isPermanent = false, message = "Not connected") - is GattRequestRejectedException -> - BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)") - is UnmetRequirementException -> - // Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the - // device (user re-enables BT, or grants permission). Surface as transient so the transport keeps - // retrying; UI can show a hint based on the message. - BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable") - else -> null -} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt index 5e85a52f8..c636d4718 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt @@ -48,7 +48,9 @@ suspend fun retryBleOperation( Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" } throw e } - Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." } + Logger.w(e) { + "[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..." + } delay(delayMs) } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt new file mode 100644 index 000000000..9e32e4602 --- /dev/null +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Represents a BLE device known by address only (e.g. from bonded list) without an active advertisement. */ +class DirectBleDevice(override val address: String, override val name: String? = null) : BleDevice { + private val _state = MutableStateFlow(BleConnectionState.Disconnected) + override val state: StateFlow = _state.asStateFlow() + + override val isBonded: Boolean = true + + override val isConnected: Boolean + get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address + + @OptIn(com.juul.kable.ExperimentalApi::class) + override suspend fun readRssi(): Int { + val peripheral = ActiveBleConnection.activePeripheral + return if (peripheral != null && ActiveBleConnection.activeAddress == address) { + peripheral.rssi() + } else { + 0 + } + } + + override suspend fun bond() { + // DirectBleDevice assumes we are already bonded. + } + + fun updateState(newState: BleConnectionState) { + _state.value = newState + } +} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index f658d234c..5265127c1 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -18,11 +18,9 @@ package org.meshtastic.core.ble import co.touchlab.kermit.Logger import com.juul.kable.Peripheral -import com.juul.kable.PeripheralBuilder import com.juul.kable.State import com.juul.kable.WriteType import com.juul.kable.characteristicOf -import com.juul.kable.logs.Logging import com.juul.kable.writeWithoutResponse import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -32,6 +30,7 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.launchIn @@ -40,7 +39,6 @@ import kotlinx.coroutines.job import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid /** [BleService] implementation backed by a Kable [Peripheral] for a specific GATT service. */ @@ -52,9 +50,6 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui override fun observe(characteristic: BleCharacteristic) = peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid)) - override fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit) = - peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid), onSubscription) - override suspend fun read(characteristic: BleCharacteristic): ByteArray = peripheral.read(characteristicOf(serviceUuid, characteristic.uuid)) @@ -83,11 +78,8 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui /** * [BleConnection] implementation using Kable for cross-platform BLE communication. * - * Manages peripheral lifecycle, connection state tracking, and GATT service profile access. - * - * Connection attempts follow Kable's recommended pattern from the SensorTag sample: try a direct connect first, then - * fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller - * ([BleRadioTransport]) owns the macro-level retry/backoff loop. + * Manages peripheral lifecycle (connect with exponential backoff, disconnect, reconnect), connection state tracking, + * and GATT service profile access. */ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { @@ -96,8 +88,10 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { private var connectionScope: CoroutineScope? = null companion object { - /** Settle delay between a direct connect failure and the autoConnect fallback attempt. */ - private val AUTOCONNECT_FALLBACK_DELAY = 1.seconds + private const val INITIAL_RETRY_DELAY_MS = 1000L + private const val MAX_RETRY_DELAY_MS = 30_000L + private const val MAX_CONNECT_RETRIES = 15 + private const val BACKOFF_MULTIPLIER = 2 } private val _deviceFlow = MutableSharedFlow(replay = 1) @@ -114,32 +108,47 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { ) override val connectionState: SharedFlow = _connectionState.asSharedFlow() - @Suppress("CyclomaticComplexMethod", "LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") override suspend fun connect(device: BleDevice) { - val meshtasticDevice = device as? MeshtasticBleDevice ?: error("Unsupported BleDevice type: ${device::class}") - var autoConnect = meshtasticDevice.advertisement == null - - /** Applies logging, observation exception handling, and platform config shared by both peripheral types. */ - fun PeripheralBuilder.commonConfig() { - logging { - engine = KermitLogEngine - level = Logging.Level.Events - identifier = device.address - } - observationExceptionHandler { cause -> - Logger.w(cause) { "[${device.address}] Observation failure suppressed" } - } - platformConfig(device) { autoConnect } - } + val autoConnect = MutableStateFlow(device is DirectBleDevice) val p = - meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } } - ?: createPeripheral(device.address) { commonConfig() } + when (device) { + is KableBleDevice -> + Peripheral(device.advertisement) { + observationExceptionHandler { cause -> + Logger.w(cause) { "[${device.address}] Observation failure suppressed" } + } + platformConfig(device) { autoConnect.value } + } + is DirectBleDevice -> + createPeripheral(device.address) { + observationExceptionHandler { cause -> + Logger.w(cause) { "[${device.address}] Observation failure suppressed" } + } + platformConfig(device) { autoConnect.value } + } + else -> error("Unsupported BleDevice type: ${device::class}") + } - cleanUpPeripheral(device.address) + // Clean up previous peripheral under NonCancellable to prevent GATT resource leaks + // if the calling coroutine is cancelled during teardown. + withContext(NonCancellable) { + try { + peripheral?.disconnect() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "[${device.address}] Failed to disconnect previous peripheral" } + } + try { + peripheral?.close() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "[${device.address}] Failed to close previous peripheral" } + } + } peripheral = p - ActiveBleConnection.active = ActiveConnection(p, device.address) + ActiveBleConnection.activePeripheral = p + ActiveBleConnection.activeAddress = device.address _deviceFlow.emit(device) @@ -153,15 +162,21 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { hasStartedConnecting = true } - meshtasticDevice.updateState(mappedState) + when (device) { + is KableBleDevice -> device.updateState(mappedState) + is DirectBleDevice -> device.updateState(mappedState) + } _connectionState.emit(mappedState) } .launchIn(scope) + var retryCount = 0 + var retryDelayMs = INITIAL_RETRY_DELAY_MS while (p.state.value !is State.Connected) { - autoConnect = + autoConnect.value = try { + // Cancel any previous connectionScope to avoid leaking the old coroutine scope. connectionScope?.let { oldScope -> Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" } oldScope.coroutineContext.job.cancel() @@ -170,50 +185,52 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { false } catch (e: CancellationException) { throw e - } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) { - if (autoConnect) { - // autoConnect already true and still failed — don't loop forever. - Logger.w { "[${device.address}] autoConnect attempt failed, giving up" } - _connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed)) - throw e + } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) { + retryCount++ + if (retryCount > MAX_CONNECT_RETRIES) { + Logger.w { "[${device.address}] Max connect retries ($MAX_CONNECT_RETRIES) exceeded" } + _connectionState.emit(BleConnectionState.Disconnected) + return } - Logger.d { "[${device.address}] Direct connect failed, falling back to autoConnect" } - delay(AUTOCONNECT_FALLBACK_DELAY) + Logger.d { "[${device.address}] Connect retry $retryCount, backoff ${retryDelayMs}ms" } + delay(retryDelayMs) + retryDelayMs = (retryDelayMs * BACKOFF_MULTIPLIER).coerceAtMost(MAX_RETRY_DELAY_MS) true } } } @Suppress("TooGenericExceptionCaught", "SwallowedException") - override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState = try { - withTimeout(timeout) { + override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try { + withTimeout(timeoutMs) { connect(device) BleConnectionState.Connected } } catch (_: TimeoutCancellationException) { // Our own timeout expired — treat as a failed attempt so callers can retry. - BleConnectionState.Disconnected(DisconnectReason.Timeout) + BleConnectionState.Disconnected } catch (e: CancellationException) { // External cancellation (scope closed) — must propagate. throw e } catch (_: Exception) { - BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed) + BleConnectionState.Disconnected } override suspend fun disconnect() = withContext(NonCancellable) { // Emit Disconnected before cancelling stateJob so downstream collectors see the // state transition. If we cancel stateJob first, the peripheral's state flow // emission of Disconnected is never forwarded to _connectionState. - _connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.LocalDisconnect)) + _connectionState.emit(BleConnectionState.Disconnected) stateJob?.cancel() stateJob = null - - safeClosePeripheral("disconnect") + peripheral?.disconnect() + peripheral?.close() peripheral = null connectionScope = null - ActiveBleConnection.active = null + ActiveBleConnection.activePeripheral = null + ActiveBleConnection.activeAddress = null _deviceFlow.emit(null) } @@ -230,29 +247,4 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection { } override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength() - - /** Ensures the previous peripheral's GATT resources are fully released. */ - private suspend fun cleanUpPeripheral(tag: String) { - withContext(NonCancellable) { safeClosePeripheral(tag) } - } - - /** - * Safely disconnects and closes the current [peripheral], logging any failures. - * - * Kable requires `close()` to release broadcast receivers on Android (Kable issue #359). Separate try/catch blocks - * ensure `close()` always runs even if `disconnect()` throws. - */ - @Suppress("TooGenericExceptionCaught") - private suspend fun safeClosePeripheral(tag: String) { - try { - peripheral?.disconnect() - } catch (e: Exception) { - Logger.w(e) { "[$tag] Failed to disconnect peripheral" } - } - try { - peripheral?.close() - } catch (e: Exception) { - Logger.w(e) { "[$tag] Failed to close peripheral" } - } - } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt index 13b8a1663..d0f3a7168 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt @@ -21,11 +21,5 @@ import org.koin.core.annotation.Single @Single class KableBleConnectionFactory : BleConnectionFactory { - /** - * Creates a new [KableBleConnection]. - * - * [tag] is unused because Kable's own log identifier is set per-peripheral inside [KableBleConnection.connect] - * using the device address, which provides more precise context than a factory-time tag. - */ override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope) } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt similarity index 51% rename from core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt rename to core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt index 3342cf24f..455779937 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt @@ -17,44 +17,32 @@ package org.meshtastic.core.ble import com.juul.kable.Advertisement -import com.juul.kable.ExperimentalApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -/** - * Unified [BleDevice] implementation for all BLE devices — scanned, bonded, or both. - * - * When created from a live BLE scan, [advertisement] is populated and used for optimal peripheral construction via - * `Peripheral(advertisement)`. When created from the OS bonded device list (address only), [advertisement] is `null` - * and the peripheral is constructed via `createPeripheral(address)` with `autoConnect = true`. - * - * @param address The device's MAC address (or platform identifier string). - * @param name The device's display name, if known. - * @param advertisement The Kable [Advertisement] from a live scan, or `null` for bonded-only devices. - */ -class MeshtasticBleDevice( - override val address: String, - override val name: String? = null, - val advertisement: Advertisement? = null, -) : BleDevice { +class KableBleDevice(val advertisement: Advertisement) : BleDevice { + override val name: String? + get() = advertisement.name - private val _state = MutableStateFlow(BleConnectionState.Disconnected()) - override val state: StateFlow = _state.asStateFlow() + override val address: String + get() = advertisement.identifier.toString() + + private val _state = MutableStateFlow(BleConnectionState.Disconnected) + override val state: StateFlow = _state // Bonding is handled by the OS pairing dialog on Android; on desktop Kable connects directly. override val isBonded: Boolean = true override val isConnected: Boolean - get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address + get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address - @OptIn(ExperimentalApi::class) + @OptIn(com.juul.kable.ExperimentalApi::class) override suspend fun readRssi(): Int { - val active = ActiveBleConnection.active - return if (active != null && active.address == address) { - active.peripheral.rssi() + val peripheral = ActiveBleConnection.activePeripheral + return if (peripheral != null && ActiveBleConnection.activeAddress == address) { + peripheral.rssi() } else { - advertisement?.rssi ?: 0 + advertisement.rssi } } @@ -62,7 +50,6 @@ class MeshtasticBleDevice( // No-op: bonding is OS-managed on Android and not required on desktop. } - /** Updates the tracked connection state. Called by [KableBleConnection] when the peripheral state changes. */ internal fun updateState(newState: BleConnectionState) { _state.value = newState } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt index 5e91b3459..d9e27704f 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.ble import com.juul.kable.Scanner -import com.juul.kable.logs.Logging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.withTimeoutOrNull @@ -29,10 +28,6 @@ import kotlin.uuid.Uuid class KableBleScanner : BleScanner { override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow { val scanner = Scanner { - logging { - engine = KermitLogEngine - level = Logging.Level.Events - } // Use separate match blocks so each filter is evaluated independently (OR semantics). // Combining address and service UUID in a single match{} creates an AND filter which // silently drops results on OEM stacks (Samsung, Xiaomi) when the device uses a @@ -48,15 +43,7 @@ class KableBleScanner : BleScanner { // By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly. return channelFlow { withTimeoutOrNull(timeout) { - scanner.advertisements.collect { advertisement -> - send( - MeshtasticBleDevice( - address = advertisement.identifier.toString(), - name = advertisement.name, - advertisement = advertisement, - ), - ) - } + scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) } } } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt index 3f0e61864..46ace854f 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt @@ -18,101 +18,110 @@ package org.meshtastic.core.ble import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC +import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC -import kotlin.time.Duration.Companion.milliseconds /** * [MeshtasticRadioProfile] implementation using Kable BLE characteristics. * - * Uses the standard Meshtastic BLE protocol: FROMNUM notifications trigger polling reads on the FROMRADIO - * characteristic. The firmware gates FROMNUM notifications behind `STATE_SEND_PACKETS`, so during the config handshake - * we seed the drain trigger to poll proactively. + * Supports both the modern `FROMRADIOSYNC` characteristic (single observe stream) and the legacy `FROMNUM` + + * `FROMRADIO` polling fallback for older firmware versions. */ class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile { private val toRadio = service.characteristic(TORADIO_CHARACTERISTIC) private val fromRadioChar = service.characteristic(FROMRADIO_CHARACTERISTIC) + private val fromRadioSync = service.characteristic(FROMRADIOSYNC_CHARACTERISTIC) private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC) private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC) companion object { - private val TRANSIENT_RETRY_DELAY = 500.milliseconds + private const val TRANSIENT_RETRY_DELAY_MS = 500L } - private val subscriptionReady = CompletableDeferred() - - /** Seed with replay=1 so the config-handshake drain starts before FROMNUM notifications are gated in. */ + // replay = 1: a seed emission placed here before the collector starts is replayed to the + // collector immediately on subscription. This is what drives the initial FROMRADIO poll + // during the config-handshake phase, where the firmware suppresses FROMNUM notifications + // (it only emits them in STATE_SEND_PACKETS). Without the initial replay the entire config + // stream would be silently skipped on devices that lack FROMRADIOSYNC. private val triggerDrain = MutableSharedFlow(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) + // Using observe() for fromRadioSync or legacy read loop for fromRadio @Suppress("TooGenericExceptionCaught", "SwallowedException") override val fromRadio: Flow = channelFlow { + // Try to observe FROMRADIOSYNC if available. If it fails, fallback to FROMNUM/FROMRADIO. + // This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation. launch { - if (service.hasCharacteristic(fromNum)) { - service - .observe(fromNum) { - Logger.d { "FROMNUM CCCD written — notifications enabled" } - subscriptionReady.complete(Unit) + try { + if (service.hasCharacteristic(fromRadioSync)) { + service.observe(fromRadioSync).collect { send(it) } + } else { + error("fromRadioSync missing") + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Fallback to legacy FROMNUM/FROMRADIO polling. + // Wire up FROMNUM notifications for steady-state packet delivery. + launch { + if (service.hasCharacteristic(fromNum)) { + service.observe(fromNum).collect { triggerDrain.tryEmit(Unit) } } - .collect { triggerDrain.tryEmit(Unit) } - } else { - subscriptionReady.complete(Unit) - } - } - triggerDrain.tryEmit(Unit) - triggerDrain.collect { - var keepReading = true - while (keepReading) { - try { - if (!service.hasCharacteristic(fromRadioChar)) { - keepReading = false - continue + } + // Seed the replay buffer so the collector below starts draining immediately. + // The firmware does NOT send FROMNUM notifications during the config handshake + // (it gates them on STATE_SEND_PACKETS). Without this seed the entire config + // stream would never be read on devices that lack FROMRADIOSYNC. + triggerDrain.tryEmit(Unit) + triggerDrain.collect { + var keepReading = true + while (keepReading) { + try { + if (!service.hasCharacteristic(fromRadioChar)) { + keepReading = false + continue + } + val packet = service.read(fromRadioChar) + if (packet.isEmpty()) keepReading = false else send(packet) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" } + keepReading = false + // Don't permanently stop — the next triggerDrain emission will retry. + delay(TRANSIENT_RETRY_DELAY_MS) + } } - val packet = service.read(fromRadioChar) - if (packet.isEmpty()) keepReading = false else send(packet) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" } - keepReading = false - delay(TRANSIENT_RETRY_DELAY) } } } } - override val logRadio: Flow = - if (service.hasCharacteristic(logRadioChar)) { - service.observe(logRadioChar).catch { e -> - if (e is CancellationException) throw e - // logRadio is optional — swallow observation errors silently. + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override val logRadio: Flow = channelFlow { + try { + if (service.hasCharacteristic(logRadioChar)) { + service.observe(logRadioChar).collect { send(it) } } - } else { - emptyFlow() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // logRadio is optional, ignore if not found } + } override suspend fun sendToRadio(packet: ByteArray) { service.write(toRadio, packet, service.preferredWriteType(toRadio)) triggerDrain.tryEmit(Unit) } - - override fun requestDrain() { - triggerDrain.tryEmit(Unit) - } - - override suspend fun awaitSubscriptionReady() { - subscriptionReady.await() - } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt index 4bd395dc5..7a03a3d89 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt @@ -25,33 +25,14 @@ import com.juul.kable.State * state emitted by StateFlow upon subscription. * @return the mapped [BleConnectionState], or null if the state should be ignored. */ -fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? = when (this) { - is State.Connecting -> BleConnectionState.Connecting - is State.Connected -> BleConnectionState.Connected - is State.Disconnecting -> BleConnectionState.Disconnecting - is State.Disconnected -> - if (hasStartedConnecting) BleConnectionState.Disconnected(status.toDisconnectReason()) else null -} - -/** - * Maps Kable's [State.Disconnected.Status] to [DisconnectReason]. - * - * Groups platform-specific GATT/CBError codes into broad categories that the reconnect logic can act on without leaking - * platform details. - */ -fun State.Disconnected.Status?.toDisconnectReason(): DisconnectReason = when (this) { - null -> DisconnectReason.Unknown - State.Disconnected.Status.CentralDisconnected -> DisconnectReason.LocalDisconnect - State.Disconnected.Status.PeripheralDisconnected -> DisconnectReason.RemoteDisconnect - State.Disconnected.Status.Failed, - State.Disconnected.Status.L2CapFailure, - -> DisconnectReason.ConnectionFailed - State.Disconnected.Status.Timeout, - State.Disconnected.Status.LinkManagerProtocolTimeout, - -> DisconnectReason.Timeout - State.Disconnected.Status.Cancelled -> DisconnectReason.Cancelled - State.Disconnected.Status.EncryptionTimedOut -> DisconnectReason.EncryptionFailed - State.Disconnected.Status.ConnectionLimitReached -> DisconnectReason.ConnectionFailed - State.Disconnected.Status.UnknownDevice -> DisconnectReason.ConnectionFailed - is State.Disconnected.Status.Unknown -> DisconnectReason.PlatformSpecific(status) +fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? { + return when (this) { + is State.Connecting -> BleConnectionState.Connecting + is State.Connected -> BleConnectionState.Connected + is State.Disconnecting -> BleConnectionState.Disconnecting + is State.Disconnected -> { + if (!hasStartedConnecting) return null + BleConnectionState.Disconnected + } + } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt deleted file mode 100644 index 6884dc9e1..000000000 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.ble - -import co.touchlab.kermit.Logger -import com.juul.kable.logs.LogEngine - -/** - * Bridges Kable's internal logging to [Kermit][Logger] so BLE lifecycle events (connect, disconnect, subscribe, GATT - * operations) appear in the standard app logs rather than going to [System.out] via Kable's default - * [com.juul.kable.logs.SystemLogEngine]. - */ -internal object KermitLogEngine : LogEngine { - override fun verbose(throwable: Throwable?, tag: String, message: String) { - Logger.v(throwable) { "[$tag] $message" } - } - - override fun debug(throwable: Throwable?, tag: String, message: String) { - Logger.d(throwable) { "[$tag] $message" } - } - - override fun info(throwable: Throwable?, tag: String, message: String) { - Logger.i(throwable) { "[$tag] $message" } - } - - override fun warn(throwable: Throwable?, tag: String, message: String) { - Logger.w(throwable) { "[$tag] $message" } - } - - override fun error(throwable: Throwable?, tag: String, message: String) { - Logger.e(throwable) { "[$tag] $message" } - } - - override fun assert(throwable: Throwable?, tag: String, message: String) { - Logger.e(throwable) { "[$tag] $message" } - } -} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt index f69214187..389516521 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt @@ -38,6 +38,8 @@ object MeshtasticBleConstants { /** Characteristic for receiving log notifications from the radio. */ val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547") + val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d") + // --- OTA Characteristics --- /** The Meshtastic OTA service UUID (ESP32 Unified OTA). */ diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt index 7a69e9524..d1a557a42 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt @@ -28,22 +28,4 @@ interface MeshtasticRadioProfile { /** Sends a packet to the radio. */ suspend fun sendToRadio(packet: ByteArray) - - /** - * Requests a drain of the FROMRADIO characteristic without writing to TORADIO. - * - * This is useful when the firmware has queued a response (e.g. `queueStatus` after a heartbeat) but did not send a - * FROMNUM notification. Without an explicit drain trigger the response would sit unread until the next unrelated - * FROMNUM notification arrives. - */ - fun requestDrain() {} - - /** - * Suspends until GATT notifications are enabled (CCCD written) for the primary observation characteristic. - * - * Callers should await this before triggering the Meshtastic handshake (`want_config_id`) to guarantee that FROMNUM - * notifications will be delivered. The default implementation returns immediately for profiles where CCCD readiness - * is not observable (e.g. fakes and non-BLE transports). - */ - suspend fun awaitSubscriptionReady() {} } diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt deleted file mode 100644 index 1170b973b..000000000 --- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.ble - -import com.juul.kable.GattStatusException -import com.juul.kable.NotConnectedException -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * Tests for [classifyBleException] — the boundary between Kable types and the transport layer. - * - * [GattRequestRejectedException] and [UnmetRequirementException] have `internal` constructors in Kable, so they cannot - * be instantiated from outside the library. The `else -> null` branch covers the fallback for any unrecognised - * throwable. - */ -class BleExceptionClassifierTest { - - @Test - fun `GattStatusException maps to non-permanent with status code`() { - val ex = GattStatusException(message = "GATT failure", status = 133) - val info = ex.classifyBleException() - assertNotNull(info) - assertFalse(info.isPermanent) - assertEquals(133, info.gattStatus) - assertTrue(info.message.contains("133")) - } - - @Test - fun `NotConnectedException maps to non-permanent without status code`() { - val ex = NotConnectedException("disconnected") - val info = ex.classifyBleException() - assertNotNull(info) - assertFalse(info.isPermanent) - assertNull(info.gattStatus) - assertEquals("Not connected", info.message) - } - - @Test - fun `unrelated exception returns null`() { - val ex = IllegalStateException("something else") - assertNull(ex.classifyBleException()) - } - - @Test - fun `RuntimeException returns null`() { - assertNull(RuntimeException("boom").classifyBleException()) - } -} diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt deleted file mode 100644 index d947dd04d..000000000 --- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.ble - -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals - -/** Tests for [DisconnectReason] and [BleConnectionState.Disconnected]. */ -class DisconnectReasonTest { - - @Test - @Suppress("MagicNumber") - fun `PlatformSpecific toString includes status code`() { - val reason = DisconnectReason.PlatformSpecific(133) - val str = reason.toString() - assertContains(str, "133", message = "PlatformSpecific.toString() should include the status code") - } - - @Test - fun `Disconnected default reason is Unknown`() { - val state = BleConnectionState.Disconnected() - assertEquals(DisconnectReason.Unknown, state.reason) - } - - @Test - fun `Disconnected preserves explicit reason`() { - val state = BleConnectionState.Disconnected(DisconnectReason.Timeout) - assertEquals(DisconnectReason.Timeout, state.reason) - } - - @Test - fun `data object reasons are singletons`() { - assertEquals(DisconnectReason.Unknown, DisconnectReason.Unknown) - assertEquals(DisconnectReason.LocalDisconnect, DisconnectReason.LocalDisconnect) - } -} diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt deleted file mode 100644 index 64286fd70..000000000 --- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.ble - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.testing.FakeBleService -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * Tests for [KableMeshtasticRadioProfile] — the GATT characteristic orchestration layer. - * - * Uses [FakeBleService] from `core:testing`. Since [FakeBleService] inherits the default [BleService.observe] overload - * (which invokes `onSubscription` via `onStart`), `awaitSubscriptionReady()` completes immediately — matching the - * behaviour expected from non-Kable implementations. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class KableMeshtasticRadioProfileTest { - - private fun createService(): FakeBleService = FakeBleService().apply { - addCharacteristic(MeshtasticBleConstants.FROMNUM_CHARACTERISTIC) - addCharacteristic(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC) - addCharacteristic(MeshtasticBleConstants.TORADIO_CHARACTERISTIC) - } - - @Test - fun `awaitSubscriptionReady completes when using FakeBleService`() = runTest { - val service = createService() - val profile = KableMeshtasticRadioProfile(service) - - // Start collecting fromRadio to activate the observe() flow (which triggers onSubscription) - val collectJob = launch { profile.fromRadio.first() } - advanceUntilIdle() - - // Should not hang — FakeBleService's default observe(char, onSubscription) fires onSubscription eagerly - profile.awaitSubscriptionReady() - - collectJob.cancel() - } - - @Test - fun `sendToRadio writes to TORADIO and triggers drain`() = runTest { - val service = createService() - val profile = KableMeshtasticRadioProfile(service) - val testData = byteArrayOf(1, 2, 3) - - // Enqueue empty read so the drain loop terminates - service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0)) - - profile.sendToRadio(testData) - - assertEquals(1, service.writes.size) - assertTrue(service.writes[0].data.contentEquals(testData)) - } - - @Test - fun `fromRadio emits packets from FROMRADIO reads`() = runTest { - val service = createService() - val profile = KableMeshtasticRadioProfile(service) - - val packet1 = byteArrayOf(10, 20, 30) - service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, packet1) - // Empty read terminates the drain loop - service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0)) - - val received = async { profile.fromRadio.first() } - advanceUntilIdle() - - assertTrue(received.await().contentEquals(packet1)) - } - - @Test - fun `requestDrain triggers additional FROMRADIO reads`() = runTest { - val service = createService() - val profile = KableMeshtasticRadioProfile(service) - - val received = mutableListOf() - - // Start the fromRadio collector - val collectJob = launch { profile.fromRadio.collect { received.add(it) } } - advanceUntilIdle() - - // First drain should have completed (initial seed) with nothing queued. - // Now enqueue a packet and trigger a manual drain. - val latePacket = byteArrayOf(99) - service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, latePacket) - service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0)) - profile.requestDrain() - advanceUntilIdle() - - assertEquals(1, received.size) - assertTrue(received[0].contentEquals(latePacket)) - - collectJob.cancel() - } - - @Test - fun `MeshtasticRadioProfile default awaitSubscriptionReady returns immediately`() = runTest { - val profile = - object : MeshtasticRadioProfile { - override val fromRadio = emptyFlow() - override val logRadio = emptyFlow() - - override suspend fun sendToRadio(packet: ByteArray) {} - } - // Should not hang — default implementation is a no-op - profile.awaitSubscriptionReady() - } -} diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt deleted file mode 100644 index 18c7be4da..000000000 --- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.ble - -import com.juul.kable.State -import kotlinx.coroutines.test.TestScope -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNull - -/** Tests for [toBleConnectionState] and [toDisconnectReason] mappings. */ -class KableStateMappingTest { - - // --- toBleConnectionState --- - - @Test - fun `Connecting maps to BleConnectionState Connecting`() { - val result = State.Connecting.Bluetooth.toBleConnectionState(hasStartedConnecting = false) - assertIs(result) - } - - @Test - fun `Connected maps to BleConnectionState Connected`() { - val scope = TestScope() - val result = State.Connected(scope).toBleConnectionState(hasStartedConnecting = true) - assertIs(result) - } - - @Test - fun `Disconnecting maps to BleConnectionState Disconnecting`() { - val result = State.Disconnecting.toBleConnectionState(hasStartedConnecting = true) - assertIs(result) - } - - @Test - fun `Disconnected before connecting started returns null`() { - val result = State.Disconnected(status = null).toBleConnectionState(hasStartedConnecting = false) - assertNull(result) - } - - @Test - fun `Disconnected after connecting started maps with reason`() { - val result = - State.Disconnected(State.Disconnected.Status.Timeout).toBleConnectionState(hasStartedConnecting = true) - assertIs(result) - assertEquals(DisconnectReason.Timeout, result.reason) - } - - // --- toDisconnectReason --- - - @Test - fun `null status maps to Unknown`() { - assertEquals(DisconnectReason.Unknown, null.toDisconnectReason()) - } - - @Test - fun `CentralDisconnected maps to LocalDisconnect`() { - assertEquals( - DisconnectReason.LocalDisconnect, - State.Disconnected.Status.CentralDisconnected.toDisconnectReason(), - ) - } - - @Test - fun `PeripheralDisconnected maps to RemoteDisconnect`() { - assertEquals( - DisconnectReason.RemoteDisconnect, - State.Disconnected.Status.PeripheralDisconnected.toDisconnectReason(), - ) - } - - @Test - fun `Failed maps to ConnectionFailed`() { - assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.Failed.toDisconnectReason()) - } - - @Test - fun `Timeout maps to Timeout`() { - assertEquals(DisconnectReason.Timeout, State.Disconnected.Status.Timeout.toDisconnectReason()) - } - - @Test - fun `LinkManagerProtocolTimeout maps to Timeout`() { - assertEquals( - DisconnectReason.Timeout, - State.Disconnected.Status.LinkManagerProtocolTimeout.toDisconnectReason(), - ) - } - - @Test - fun `Cancelled maps to Cancelled`() { - assertEquals(DisconnectReason.Cancelled, State.Disconnected.Status.Cancelled.toDisconnectReason()) - } - - @Test - fun `EncryptionTimedOut maps to EncryptionFailed`() { - assertEquals( - DisconnectReason.EncryptionFailed, - State.Disconnected.Status.EncryptionTimedOut.toDisconnectReason(), - ) - } - - @Test - fun `L2CapFailure maps to ConnectionFailed`() { - assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.L2CapFailure.toDisconnectReason()) - } - - @Test - fun `ConnectionLimitReached maps to ConnectionFailed`() { - assertEquals( - DisconnectReason.ConnectionFailed, - State.Disconnected.Status.ConnectionLimitReached.toDisconnectReason(), - ) - } - - @Test - fun `UnknownDevice maps to ConnectionFailed`() { - assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.UnknownDevice.toDisconnectReason()) - } - - @Test - @Suppress("MagicNumber") - fun `Unknown status maps to PlatformSpecific with code`() { - val result = State.Disconnected.Status.Unknown(status = 42).toDisconnectReason() - assertIs(result) - assertEquals(42, result.code) - } -} diff --git a/core/common/README.md b/core/common/README.md index 979586213..da7700ac5 100644 --- a/core/common/README.md +++ b/core/common/README.md @@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers: - **Time**: Utilities for handling timestamps and durations. - **Exceptions**: Standardized exception types for common error scenarios. -### 2. `MetricFormatter.kt` -Centralized utility for display strings — temperature, voltage, current, percent, humidity, pressure, SNR, RSSI. Ensures consistent unit spacing and formatting across all UI surfaces. +### 2. `ByteUtils.kt` +Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets. ### 3. `BuildConfigProvider.kt` An interface for accessing build-time configuration in a multiplatform-friendly way. diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index e4d94943e..08ec08865 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -37,7 +37,6 @@ kotlin { implementation(libs.kotlinx.coroutines.core) api(libs.kotlinx.datetime) api(libs.okio) - api(libs.uri.kmp) implementation(libs.kermit) } androidMain.dependencies { api(libs.androidx.core.ktx) } diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt new file mode 100644 index 000000000..a99bccd84 --- /dev/null +++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import android.net.Uri + +actual class CommonUri(private val uri: Uri) { + actual val host: String? + get() = uri.host + + actual val fragment: String? + get() = uri.fragment + + actual val pathSegments: List + get() = uri.pathSegments + + actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key) + + actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = + uri.getBooleanQueryParameter(key, defaultValue) + + actual override fun toString(): String = uri.toString() + + actual companion object { + actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString)) + } + + fun toUri(): Uri = uri +} + +actual fun CommonUri.toPlatformUri(): Any = this.toUri() diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt similarity index 63% rename from feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt rename to core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt index 3ef5c44ef..7669a66b0 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt +++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt @@ -14,13 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.firmware +package org.meshtastic.core.common.util -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import org.meshtastic.core.common.di.ApplicationCoroutineScope +import android.net.Uri -internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : - ApplicationCoroutineScope, - CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) +/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */ +fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString) + +/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */ +fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString()) diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt new file mode 100644 index 000000000..c27040e73 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.common + +/** Utility function to make it easy to declare byte arrays */ +fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } + +fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) } + +private const val BYTE_MASK = 0xff diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt deleted file mode 100644 index 2a27b9690..000000000 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.di - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.ioDispatcher - -/** - * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. - * - * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled - * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not - * cancel siblings, and by [ioDispatcher] so work runs off the main thread. - * - * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch - * and should be used sparingly. - */ -interface ApplicationCoroutineScope : CoroutineScope - -@Single(binds = [ApplicationCoroutineScope::class]) -internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { - override val coroutineContext = SupervisorJob() + ioDispatcher -} diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt index 00b15861f..7079cbf5e 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt @@ -16,14 +16,22 @@ */ package org.meshtastic.core.common.util -import com.eygraber.uri.Uri +/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */ +expect class CommonUri { + val host: String? + val fragment: String? + val pathSegments: List -/** - * Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp). - * - * This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works - * identically on Android, JVM, and iOS without platform stubs. - * - * On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`. - */ -typealias CommonUri = Uri + fun getQueryParameter(key: String): String? + + fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean + + override fun toString(): String + + companion object { + fun parse(uriString: String): CommonUri + } +} + +/** Extension to convert platform Uri to CommonUri in Android source sets. */ +expect fun CommonUri.toPlatformUri(): Any diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt index 92137375c..ccd565286 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.common.util import co.touchlab.kermit.Logger -import kotlinx.coroutines.CancellationException object Exceptions { /** Set by the application to provide a custom crash reporting implementation. */ @@ -48,12 +47,10 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) { } } -/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */ +/** Suspend-compatible variant of [ignoreException]. */ suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) { try { inner() - } catch (e: CancellationException) { - throw e } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) { if (!silent) { Logger.w(ex) { "Ignoring exception" } @@ -72,41 +69,3 @@ fun exceptionReporter(inner: () -> Unit) { Exceptions.report(ex, "exceptionReporter", "Uncaught Exception") } } - -/** - * Like [kotlin.runCatching], but re-throws [CancellationException] to preserve structured concurrency. Use this instead - * of [runCatching] in coroutine contexts. - */ -@Suppress("TooGenericExceptionCaught") -inline fun safeCatching(block: () -> T): Result = try { - Result.success(block()) -} catch (e: CancellationException) { - throw e -} catch (e: Exception) { - Result.failure(e) -} - -/** Like [kotlin.runCatching] receiver variant, but re-throws [CancellationException]. */ -@Suppress("TooGenericExceptionCaught") -inline fun T.safeCatching(block: T.() -> R): Result = try { - Result.success(block()) -} catch (e: CancellationException) { - throw e -} catch (e: Exception) { - Result.failure(e) -} - -/** - * Like [safeCatching] but also catches JVM [Error]s (e.g. [ExceptionInInitializerError] raised by compose-resources' - * lazy skiko initialization on the desktop JVM test classpath). Still re-throws [CancellationException] so structured - * concurrency is preserved. Use when the block invokes code whose failure modes include static-initializer errors and - * the caller only needs a best-effort fallback. - */ -@Suppress("TooGenericExceptionCaught") -inline fun safeCatchingAll(block: () -> T): Result = try { - Result.success(block()) -} catch (e: CancellationException) { - throw e -} catch (t: Throwable) { - Result.failure(t) -} diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt index 7a24819a7..d54455df8 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -16,114 +16,5 @@ */ package org.meshtastic.core.common.util -/** - * Pure-Kotlin multiplatform string formatting. - * - * Implements the subset of Java's `String.format()` patterns used in this codebase: - * - `%s`, `%d` — positional or sequential string/integer - * - `%N$s`, `%N$d` — explicit positional string/integer - * - `%N$.Nf`, `%.Nf` — float with decimal precision - * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width) - * - `%%` — literal percent - */ -@Suppress("CyclomaticComplexMethod", "LongMethod", "LoopWithTooManyJumpStatements") -fun formatString(pattern: String, vararg args: Any?): String = buildString { - var i = 0 - var autoIndex = 0 - while (i < pattern.length) { - if (pattern[i] != '%') { - append(pattern[i]) - i++ - continue - } - i++ // skip '%' - if (i >= pattern.length) break - - // Literal %% - if (pattern[i] == '%') { - append('%') - i++ - continue - } - - // Parse optional positional index (N$) - var explicitIndex: Int? = null - val startPos = i - while (i < pattern.length && pattern[i].isDigit()) i++ - if (i < pattern.length && pattern[i] == '$' && i > startPos) { - explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed - i++ // skip '$' - } else { - i = startPos // rewind — digits are part of width/precision, not positional index - } - - // Parse optional flags (zero-pad) - var zeroPad = false - if (i < pattern.length && pattern[i] == '0') { - zeroPad = true - i++ - } - - // Parse optional width - var width: Int? = null - val widthStart = i - while (i < pattern.length && pattern[i].isDigit()) i++ - if (i > widthStart) { - width = pattern.substring(widthStart, i).toInt() - } - - // Parse optional precision (.N) - var precision: Int? = null - if (i < pattern.length && pattern[i] == '.') { - i++ // skip '.' - val precStart = i - while (i < pattern.length && pattern[i].isDigit()) i++ - if (i > precStart) { - precision = pattern.substring(precStart, i).toInt() - } - } - - // Parse conversion character - if (i >= pattern.length) break - val conversion = pattern[i] - i++ - - val argIndex = explicitIndex ?: autoIndex++ - val arg = args.getOrNull(argIndex) - - when (conversion) { - 's' -> append(arg?.toString() ?: "null") - 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0") - 'f' -> { - val value = (arg as? Number)?.toDouble() ?: 0.0 - val places = precision ?: DEFAULT_FLOAT_PRECISION - append(NumberFormatter.format(value, places)) - } - 'x', - 'X', - -> { - val value = (arg as? Number)?.toLong() ?: 0L - // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour. - val masked = if (arg is Int) value and INT_MASK else value - var hex = masked.toString(HEX_RADIX) - if (conversion == 'X') hex = hex.uppercase() - val padChar = if (zeroPad) '0' else ' ' - val padWidth = width ?: 0 - append(hex.padStart(padWidth, padChar)) - } - else -> { - // Unknown conversion — reproduce original token - append('%') - if (explicitIndex != null) append("${explicitIndex + 1}$") - if (zeroPad) append('0') - if (width != null) append(width) - if (precision != null) append(".$precision") - append(conversion) - } - } - } -} - -private const val DEFAULT_FLOAT_PRECISION = 6 -private const val HEX_RADIX = 16 -private const val INT_MASK = 0xFFFFFFFFL +/** Multiplatform string formatting helper. */ +expect fun formatString(pattern: String, vararg args: Any?): String diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt index 1abb8807c..e3612dfda 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt @@ -79,7 +79,9 @@ object HomoglyphCharacterStringTransformer { * @param value original string value. * @return optimized string value. */ - fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString { - for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c) + fun optimizeUtf8StringWithHomoglyphs(value: String): String { + val stringBuilder = StringBuilder() + for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c) + return stringBuilder.toString() } } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt similarity index 62% rename from core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt index 1072801c6..0babff5b1 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright (c) 2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,14 +17,13 @@ package org.meshtastic.core.common.util /** - * Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null, - * blank, or sentinel values (`"N"`, `"NULL"`). + * A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain + * modules without coupling them to the android.net.Uri class. */ -fun normalizeAddress(addr: String?): String { - val u = addr?.trim()?.uppercase() - return when { - u.isNullOrBlank() -> "DEFAULT" - u == "N" || u == "NULL" -> "DEFAULT" - else -> u.replace(":", "") +data class MeshtasticUri(val uriString: String) { + override fun toString(): String = uriString + + companion object { + fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString) } } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt deleted file mode 100644 index 51905ff41..000000000 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -/** - * Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node, - * NodeItem, and metric screens. - * - * All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional - * for a mesh networking app where consistency matters. - */ -@Suppress("TooManyFunctions") -object MetricFormatter { - - fun temperature(celsius: Float, isFahrenheit: Boolean): String { - val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius - val unit = if (isFahrenheit) "°F" else "°C" - return "${NumberFormatter.format(value, 1)}$unit" - } - - fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V" - - fun current(milliAmps: Float, decimalPlaces: Int = 1): String = - "${NumberFormatter.format(milliAmps, decimalPlaces)} mA" - - fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%" - - fun percent(value: Int): String = "$value%" - - fun humidity(value: Float): String = percent(value, 0) - - fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa" - - fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB" - - fun rssi(value: Int): String = "$value dBm" - - fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String = - "${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s" - - fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String = - "${NumberFormatter.format(millimeters, decimalPlaces)} mm" -} - -private const val FAHRENHEIT_SCALE = 1.8f -private const val FAHRENHEIT_OFFSET = 32 diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt similarity index 95% rename from core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt index 14dfd72c8..51f6a5c76 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt @@ -14,12 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.model.util +package org.meshtastic.core.common import kotlin.test.Test import kotlin.test.assertEquals -class CommonUtilsTest { +class ByteUtilsTest { @Test fun testByteArrayOfInts() { diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt deleted file mode 100644 index 040861b8d..000000000 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import kotlin.test.Test -import kotlin.test.assertEquals - -class AddressUtilsTest { - - @Test - fun nullReturnsDefault() { - assertEquals("DEFAULT", normalizeAddress(null)) - } - - @Test - fun blankReturnsDefault() { - assertEquals("DEFAULT", normalizeAddress("")) - assertEquals("DEFAULT", normalizeAddress(" ")) - } - - @Test - fun sentinelNReturnsDefault() { - assertEquals("DEFAULT", normalizeAddress("N")) - assertEquals("DEFAULT", normalizeAddress("n")) - } - - @Test - fun sentinelNullReturnsDefault() { - assertEquals("DEFAULT", normalizeAddress("NULL")) - assertEquals("DEFAULT", normalizeAddress("null")) - assertEquals("DEFAULT", normalizeAddress("Null")) - } - - @Test - fun stripsColons() { - assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD")) - } - - @Test - fun uppercases() { - assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd")) - } - - @Test - fun trimsWhitespace() { - assertEquals("AABBCC", normalizeAddress(" AA:BB:CC ")) - } - - @Test - fun alreadyNormalizedPassesThrough() { - assertEquals("AABBCCDD", normalizeAddress("AABBCCDD")) - } - - @Test - fun mixedCaseWithColons() { - assertEquals("AABBCC", normalizeAddress("aA:Bb:cC")) - } -} diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt index de2d20e9e..94b81f0fb 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt @@ -93,48 +93,4 @@ class FormatStringTest { fun sequentialFloatSubstitution() { assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45)) } - - // Hex format tests - - @Test - fun lowercaseHex() { - assertEquals("ff", formatString("%x", 255)) - } - - @Test - fun uppercaseHex() { - assertEquals("FF", formatString("%X", 255)) - } - - @Test - fun zeroPaddedHex() { - assertEquals("000000ff", formatString("%08x", 255)) - } - - @Test - fun zeroPaddedHexNodeId() { - assertEquals("!deadbeef", formatString("!%08x", 0xDEADBEEF.toInt())) - } - - @Test - fun hexZeroValue() { - assertEquals("00000000", formatString("%08x", 0)) - } - - @Test - fun positionalHex() { - assertEquals("Node ff id 42", formatString("Node %1\$x id %2\$d", 255, 42)) - } - - // Edge case tests - - @Test - fun trailingPercent() { - assertEquals("hello", formatString("hello%")) - } - - @Test - fun outOfBoundsArgIndex() { - assertEquals("null", formatString("%3\$s", "only_one")) - } } diff --git a/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt similarity index 67% rename from app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt index 04f0350c8..7ca9f9fe8 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt @@ -14,13 +14,16 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.di +package org.meshtastic.core.common.util -import org.koin.core.annotation.KoinApplication +import kotlin.test.Test +import kotlin.test.assertEquals -/** - * Root Koin bootstrap for Android. The K2 compiler plugin uses this to discover the full module graph when - * [org.koin.plugin.module.dsl.startKoin] is called with this type parameter. - */ -@KoinApplication(modules = [AppKoinModule::class]) -object AndroidKoinApp +class MeshtasticUriTest { + @Test + fun testParseAndToString() { + val uriString = "content://com.example.provider/file.txt" + val uri = MeshtasticUri.parse(uriString) + assertEquals(uriString, uri.toString()) + } +} diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt deleted file mode 100644 index 94781fca3..000000000 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.common.util - -import kotlin.test.Test -import kotlin.test.assertEquals - -class MetricFormatterTest { - - @Test - fun temperatureCelsius() { - assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false)) - } - - @Test - fun temperatureFahrenheit() { - assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true)) - } - - @Test - fun temperatureNegative() { - assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false)) - } - - @Test - fun voltage() { - assertEquals("3.72 V", MetricFormatter.voltage(3.72f)) - } - - @Test - fun voltageOneDecimal() { - assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1)) - } - - @Test - fun current() { - assertEquals("150.3 mA", MetricFormatter.current(150.3f)) - } - - @Test - fun percentFloat() { - assertEquals("85.5%", MetricFormatter.percent(85.5f)) - } - - @Test - fun percentInt() { - assertEquals("85%", MetricFormatter.percent(85)) - } - - @Test - fun humidity() { - assertEquals("65%", MetricFormatter.humidity(65.4f)) - } - - @Test - fun pressure() { - assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f)) - } - - @Test - fun snr() { - assertEquals("5.5 dB", MetricFormatter.snr(5.5f)) - } - - @Test - fun rssi() { - assertEquals("-90 dBm", MetricFormatter.rssi(-90)) - } - - @Test - fun temperatureFreezingFahrenheit() { - assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true)) - } - - @Test - fun temperatureBoilingFahrenheit() { - assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true)) - } - - @Test - fun voltageZero() { - assertEquals("0.00 V", MetricFormatter.voltage(0.0f)) - } - - @Test - fun currentZero() { - assertEquals("0.0 mA", MetricFormatter.current(0.0f)) - } - - @Test - fun percentZero() { - assertEquals("0%", MetricFormatter.percent(0)) - } - - @Test - fun percentHundred() { - assertEquals("100%", MetricFormatter.percent(100)) - } - - @Test - fun rssiZero() { - assertEquals("0 dBm", MetricFormatter.rssi(0)) - } - - @Test - fun snrNegative() { - assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f)) - } - - @Test - fun windSpeed() { - assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f)) - } - - @Test - fun windSpeedZero() { - assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f)) - } - - @Test - fun rainfall() { - assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f)) - } - - @Test - fun rainfallZero() { - assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f)) - } -} diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt new file mode 100644 index 000000000..c2e95a5b0 --- /dev/null +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +/** + * Apple (iOS) implementation of string formatting. + * + * Implements a subset of Java's `String.format()` patterns used in this codebase: + * - `%s`, `%d` — positional or sequential string/integer + * - `%N$s`, `%N$d` — explicit positional string/integer + * - `%N$.Nf`, `%.Nf` — float with decimal precision + * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width) + * - `%%` — literal percent + * + * This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions). + */ +actual fun formatString(pattern: String, vararg args: Any?): String = buildString { + var i = 0 + var autoIndex = 0 + while (i < pattern.length) { + if (pattern[i] != '%') { + append(pattern[i]) + i++ + continue + } + i++ // skip '%' + if (i >= pattern.length) break + + // Literal %% + if (pattern[i] == '%') { + append('%') + i++ + continue + } + + // Parse optional positional index (N$) + var explicitIndex: Int? = null + val startPos = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i < pattern.length && pattern[i] == '$' && i > startPos) { + explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed + i++ // skip '$' + } else { + i = startPos // rewind — digits are part of width/precision, not positional index + } + + // Parse optional flags (zero-pad) + var zeroPad = false + if (i < pattern.length && pattern[i] == '0') { + zeroPad = true + i++ + } + + // Parse optional width + var width: Int? = null + val widthStart = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i > widthStart) { + width = pattern.substring(widthStart, i).toInt() + } + + // Parse optional precision (.N) + var precision: Int? = null + if (i < pattern.length && pattern[i] == '.') { + i++ // skip '.' + val precStart = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i > precStart) { + precision = pattern.substring(precStart, i).toInt() + } + } + + // Parse conversion character + if (i >= pattern.length) break + val conversion = pattern[i] + i++ + + val argIndex = explicitIndex ?: autoIndex++ + val arg = args.getOrNull(argIndex) + + when (conversion) { + 's' -> append(arg?.toString() ?: "null") + 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0") + 'f' -> { + val value = (arg as? Number)?.toDouble() ?: 0.0 + val places = precision ?: DEFAULT_FLOAT_PRECISION + append(NumberFormatter.format(value, places)) + } + 'x', + 'X', + -> { + val value = (arg as? Number)?.toLong() ?: 0L + // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour. + val masked = if (arg is Int) value and INT_MASK else value + var hex = masked.toString(HEX_RADIX) + if (conversion == 'X') hex = hex.uppercase() + val padChar = if (zeroPad) '0' else ' ' + val padWidth = width ?: 0 + append(hex.padStart(padWidth, padChar)) + } + else -> { + // Unknown conversion — reproduce original token + append('%') + if (explicitIndex != null) append("${explicitIndex + 1}$") + if (zeroPad) append('0') + if (width != null) append(width) + if (precision != null) append(".$precision") + append(conversion) + } + } + } +} + +private const val DEFAULT_FLOAT_PRECISION = 6 +private const val HEX_RADIX = 16 +private const val INT_MASK = 0xFFFFFFFFL diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt index 7556105b3..35e2906ff 100644 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt @@ -22,6 +22,20 @@ actual object BuildUtils { actual val sdkInt: Int = 0 } +actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List) { + actual fun getQueryParameter(key: String): String? = null + + actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue + + actual override fun toString(): String = "" + + actual companion object { + actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList()) + } +} + +actual fun CommonUri.toPlatformUri(): Any = Any() + actual object DateFormatter { actual fun formatRelativeTime(timestampMillis: Long): String = "" diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt new file mode 100644 index 000000000..a450b9856 --- /dev/null +++ b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +/** JVM/Android implementation of string formatting. */ +actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args) diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt new file mode 100644 index 000000000..c10c015bc --- /dev/null +++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import java.net.URI + +actual class CommonUri(private val uri: URI) { + private val queryParameters: Map> by lazy { parseQueryParameters(uri.rawQuery) } + + actual val host: String? + get() = uri.host + + actual val fragment: String? + get() = uri.fragment + + actual val pathSegments: List + get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() } + + actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull() + + actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean { + val value = getQueryParameter(key) ?: return defaultValue + return value != "false" && value != "0" + } + + actual override fun toString(): String = uri.toString() + + actual companion object { + actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString)) + } + + fun toUri(): URI = uri +} + +actual fun CommonUri.toPlatformUri(): Any = this.toUri() diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt index 43ead91a2..4b8abdbd3 100644 --- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt +++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt @@ -17,6 +17,9 @@ package org.meshtastic.core.common.util import java.net.InetAddress +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.text.DateFormat import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -73,7 +76,7 @@ actual object DateFormatter { shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId)) actual fun formatDateTimeShort(timestampMillis: Long): String = - shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId)) + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis) } @Suppress("MagicNumber") @@ -98,6 +101,21 @@ actual fun String?.isValidAddress(): Boolean { } } +internal fun parseQueryParameters(rawQuery: String?): Map> = rawQuery + ?.split('&') + ?.filter { it.isNotBlank() } + ?.groupBy( + keySelector = { segment -> + val key = segment.substringBefore('=', missingDelimiterValue = segment) + URLDecoder.decode(key, StandardCharsets.UTF_8.name()) + }, + valueTransform = { segment -> + val value = segment.substringAfter('=', missingDelimiterValue = "") + URLDecoder.decode(value, StandardCharsets.UTF_8.name()) + }, + ) + .orEmpty() + private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}") private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?. - */ -package org.meshtastic.core.data.manager - -import co.touchlab.kermit.Logger -import kotlinx.atomicfu.atomic -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.proto.Heartbeat -import org.meshtastic.proto.ToRadio - -/** - * Centralized heartbeat sender for the data layer. - * - * Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's - * per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats. - * - * This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer - * with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler]. - */ -@Single -class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) { - private val nonce = atomic(0) - - /** - * Enqueues a heartbeat with a unique nonce. - * - * @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage") - */ - @Suppress("TooGenericExceptionCaught") - fun sendHeartbeat(tag: String = "handshake") { - try { - val n = nonce.incrementAndGet() - packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n))) - Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" } - } catch (e: Exception) { - Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" } - } - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt index 628528391..b0b9e8c5f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshPrefs import org.meshtastic.core.repository.PacketHandler @@ -95,7 +94,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan "lastRequest=$lastRequest window=$window max=$max", ) - safeCatching { + runCatching { packetHandler.sendToRadio( MeshPacket( from = myNodeNum, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index ab4f3a551..027947453 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -18,15 +18,12 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import okio.ByteString import okio.ByteString.Companion.toByteString -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ignoreExceptionSuspend import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MeshUser import org.meshtastic.core.model.MessageStatus @@ -45,7 +42,6 @@ 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 @@ -64,13 +60,16 @@ class MeshActionHandlerImpl( private val dataHandler: Lazy, private val analytics: PlatformAnalytics, private val meshPrefs: MeshPrefs, - private val uiPrefs: UiPrefs, private val databaseManager: DatabaseManager, private val notificationManager: NotificationManager, private val messageProcessor: Lazy, private val radioConfigRepository: RadioConfigRepository, - @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshActionHandler { + private lateinit var scope: CoroutineScope + + override fun start(scope: CoroutineScope) { + this.scope = scope + } companion object { private const val DEFAULT_REBOOT_DELAY = 5 @@ -96,7 +95,7 @@ class MeshActionHandlerImpl( is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum) is ServiceAction.SendContact -> { val accepted = - safeCatching { + runCatching { commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) } } .getOrDefault(false) @@ -203,13 +202,13 @@ class MeshActionHandlerImpl( commandSender.sendData(p) serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) dataHandler.value.rememberDataPacket(p, myNodeNum, false) - val bytes = p.bytes ?: ByteString.EMPTY + val bytes = p.bytes ?: okio.ByteString.EMPTY analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType)) } override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) { if (destNum != myNodeNum) { - val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value + val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value val currentPosition = when { provideLocation && position.isValid() -> position @@ -360,7 +359,7 @@ class MeshActionHandlerImpl( override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA val otaEvent = - AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY) + AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY) commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index cc5cc4319..f492dcd65 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -20,17 +20,17 @@ import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import org.koin.core.annotation.Named +import okio.IOException import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.HandshakeConstants import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceBroadcasts @@ -38,7 +38,9 @@ import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FileInfo import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Heartbeat import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.ToRadio import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo @@ -53,14 +55,18 @@ class MeshConfigFlowManagerImpl( private val serviceBroadcasts: ServiceBroadcasts, private val analytics: PlatformAnalytics, private val commandSender: CommandSender, - private val heartbeatSender: DataLayerHeartbeatSender, - @Named("ServiceScope") private val scope: CoroutineScope, + private val packetHandler: PacketHandler, ) : MeshConfigFlowManager { + private lateinit var scope: CoroutineScope private val wantConfigDelay = 100L /** Monotonically increasing generation so async clears from a stale handshake are discarded. */ private val handshakeGeneration = atomic(0L) + override fun start(scope: CoroutineScope) { + this.scope = scope + } + /** * Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase, * eliminating the possibility of accessing stale or uninitialized fields. @@ -78,7 +84,7 @@ class MeshConfigFlowManagerImpl( * [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed * together by [buildMyNodeInfo] at Stage 1 completion. */ - data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) : + data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, var metadata: DeviceMetadata? = null) : HandshakeState() /** @@ -87,8 +93,10 @@ class MeshConfigFlowManagerImpl( * [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until * `config_complete_id` arrives. */ - data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List = emptyList()) : - HandshakeState() + data class ReceivingNodeInfo( + val myNodeInfo: SharedMyNodeInfo, + val nodes: MutableList = mutableListOf(), + ) : HandshakeState() /** Both stages finished. The app is fully connected. */ data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState() @@ -134,31 +142,28 @@ class MeshConfigFlowManagerImpl( return } - // Warn if firmware is below the absolute minimum supported version. - // The UI layer already enforces this via FirmwareVersionCheck, so we just log here - // for diagnostics rather than hard-disconnecting. - finalizedInfo.firmwareVersion?.let { fwVersion -> - if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) { - Logger.w { - "Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " + - "protocol incompatibilities may occur" - } - } - } - handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo) Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" } connectionManager.value.onRadioConfigLoaded() scope.handledLaunch { delay(wantConfigDelay) - heartbeatSender.sendHeartbeat("inter-stage") + sendHeartbeat() delay(wantConfigDelay) Logger.i { "Requesting NodeInfo (Stage 2)" } connectionManager.value.startNodeInfoOnly() } } + private fun sendHeartbeat() { + try { + packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat())) + Logger.d { "Heartbeat sent between nonce stages" } + } catch (ex: IOException) { + Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" } + } + } + private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) { Logger.i { "NodeInfo complete (Stage 2)" } @@ -166,12 +171,16 @@ class MeshConfigFlowManagerImpl( // Transition state immediately (synchronously) to prevent duplicate handling. // The async work below (DB writes, broadcasts) proceeds without the guard. - // Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot. - // Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored. handshakeState = HandshakeState.Complete(myNodeInfo = info) + // Snapshot and clear immediately so that a concurrent stall-guard retry (which + // resends want_config_id and causes the firmware to restart the node_info burst) + // starts accumulating into a fresh list rather than doubling this batch. + val nodesToProcess = state.nodes.toList() + state.nodes.clear() + val entities = - state.nodes.mapNotNull { nodeInfo -> + nodesToProcess.mapNotNull { nodeInfo -> nodeManager.installNodeInfo(nodeInfo, withBroadcast = false) nodeManager.nodeDBbyNodeNum[nodeInfo.num] ?: run { @@ -222,7 +231,7 @@ class MeshConfigFlowManagerImpl( Logger.i { "Local Metadata received: ${metadata.firmware_version}" } val state = handshakeState if (state is HandshakeState.ReceivingConfig) { - handshakeState = state.copy(metadata = metadata) + state.metadata = metadata // Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete, // but the DB write does not need to wait until then. if (metadata != DeviceMetadata()) { @@ -236,7 +245,7 @@ class MeshConfigFlowManagerImpl( override fun handleNodeInfo(info: NodeInfo) { val state = handshakeState if (state is HandshakeState.ReceivingNodeInfo) { - handshakeState = state.copy(nodes = state.nodes + info) + state.nodes.add(info) } else { Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt index b622cedbf..06d973204 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.repository.MeshConfigHandler @@ -41,8 +40,8 @@ class MeshConfigHandlerImpl( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val nodeManager: NodeManager, - @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConfigHandler { + private lateinit var scope: CoroutineScope private val _localConfig = MutableStateFlow(LocalConfig()) override val localConfig = _localConfig.asStateFlow() @@ -50,7 +49,8 @@ class MeshConfigHandlerImpl( private val _moduleConfig = MutableStateFlow(LocalModuleConfig()) override val moduleConfig = _moduleConfig.asStateFlow() - init { + override fun start(scope: CoroutineScope) { + this.scope = scope radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope) radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index 022f3548d..5954b579c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -19,16 +19,13 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import okio.ByteString -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis @@ -60,7 +57,6 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Config import org.meshtastic.proto.Telemetry import org.meshtastic.proto.ToRadio -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit @@ -85,26 +81,17 @@ class MeshConnectionManagerImpl( private val packetRepository: PacketRepository, private val workerManager: MeshWorkerManager, private val appWidgetUpdater: AppWidgetUpdater, - private val heartbeatSender: DataLayerHeartbeatSender, - @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConnectionManager { - /** - * Serializes [onConnectionChanged] to prevent TOCTOU races when multiple coroutines emit state transitions - * concurrently (e.g. flow collector vs. sleep-timeout coroutine). - */ - private val connectionMutex = Mutex() - - private var preHandshakeJob: Job? = null + private lateinit var scope: CoroutineScope private var sleepTimeout: Job? = null private var locationRequestsJob: Job? = null private var handshakeTimeout: Job? = null private var connectTimeMsec = 0L private var connectionRestored = false - init { - // Bridge transport-level state into the canonical app-level state. - // This is the ONLY consumer of RadioInterfaceService.connectionState — it applies - // light-sleep policy and handshake awareness before writing to ServiceRepository. + @OptIn(FlowPreview::class) + override fun start(scope: CoroutineScope) { + this.scope = scope radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope) // Ensure notification title and content stay in sync with state changes @@ -138,13 +125,6 @@ class MeshConnectionManagerImpl( .launchIn(scope) } - /** - * Bridges a transport-level [ConnectionState] into the canonical app-level state. - * - * Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event - * should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state - * transition. - */ private suspend fun onRadioConnectionState(newState: ConnectionState) { val localConfig = radioConfigRepository.localConfigFlow.first() val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER @@ -161,22 +141,20 @@ class MeshConnectionManagerImpl( onConnectionChanged(effectiveState) } - private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock { + private fun onConnectionChanged(c: ConnectionState) { val current = serviceRepository.connectionState.value - if (current == c) return@withLock + if (current == c) return // If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting) if (c is ConnectionState.Connected && current is ConnectionState.Connecting) { Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" } - return@withLock + return } Logger.i { "onConnectionChanged: $current -> $c" } sleepTimeout?.cancel() sleepTimeout = null - preHandshakeJob?.cancel() - preHandshakeJob = null handshakeTimeout?.cancel() handshakeTimeout = null @@ -197,26 +175,16 @@ class MeshConnectionManagerImpl( serviceRepository.setConnectionState(ConnectionState.Connecting) } serviceBroadcasts.broadcastConnection() + Logger.i { "Starting mesh handshake (Stage 1)" } connectTimeMsec = nowMillis - - // Send a wake-up heartbeat before the config request. The firmware may be in a - // power-saving state where the NimBLE callback context needs warming up. The 100ms - // delay ensures the heartbeat BLE write is enqueued before the want_config_id - // (sendToRadio is fire-and-forget through async coroutine launches). - preHandshakeJob = - scope.handledLaunch { - heartbeatSender.sendHeartbeat("pre-handshake") - delay(PRE_HANDSHAKE_SETTLE_MS) - Logger.i { "Starting mesh handshake (Stage 1)" } - startConfigOnly() - } + startConfigOnly() } - private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) { + private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) { handshakeTimeout?.cancel() handshakeTimeout = scope.handledLaunch { - delay(timeout) + delay(HANDSHAKE_TIMEOUT) if (serviceRepository.connectionState.value is ConnectionState.Connecting) { // Attempt one retry. Note: the firmware silently drops identical consecutive // writes (per-connection dedup). If the first want_config_id was received and @@ -237,7 +205,7 @@ class MeshConnectionManagerImpl( private fun tearDownConnection() { packetHandler.stopPacketQueue() - commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect. + commandSender.setSessionPasskey(okio.ByteString.EMPTY) // Prevent stale passkey on reconnect. locationManager.stop() mqttManager.stop() } @@ -292,19 +260,19 @@ class MeshConnectionManagerImpl( override fun startConfigOnly() { val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) } - startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action) + startHandshakeStallGuard(1, action) action() } override fun startNodeInfoOnly() { val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) } - startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action) + startHandshakeStallGuard(2, action) action() } override fun onRadioConfigLoaded() { scope.handledLaunch { - val queuedPackets = packetRepository.getQueuedPackets() + val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList() queuedPackets.forEach { packet -> try { workerManager.enqueueSendMessage(packet.id) @@ -334,7 +302,8 @@ class MeshConnectionManagerImpl( // Start MQTT if enabled scope.handledLaunch { val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - mqttManager.startProxy( + mqttManager.start( + scope, moduleConfig.mqtt?.enabled == true, moduleConfig.mqtt?.proxy_to_client_enabled == true, ) @@ -381,12 +350,11 @@ class MeshConnectionManagerImpl( updateStatusNotification(t) } - override fun updateStatusNotification(telemetry: Telemetry?) { + override fun updateStatusNotification(telemetry: Telemetry?): Any = serviceNotifications.updateServiceStateNotification( serviceRepository.connectionState.value, telemetry = telemetry, ) - } companion object { private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30 @@ -396,23 +364,7 @@ class MeshConnectionManagerImpl( // cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour. private const val MAX_SLEEP_TIMEOUT_SECONDS = 300 - /** - * Delay between the pre-handshake heartbeat and the want_config_id send. - * - * Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the - * config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding - * negligible connection latency. - */ - private const val PRE_HANDSHAKE_SETTLE_MS = 100L - - private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds - - /** - * Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes. - * 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+ - * nodes. - */ - private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds + private val HANDSHAKE_TIMEOUT = 30.seconds // Shorter window for the retry attempt: if the device genuinely didn't receive the // first want_config_id the retry completes within a few seconds. Waiting another 30s diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 384f722d8..07521b21c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -22,8 +22,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import okio.ByteString -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis @@ -96,8 +94,14 @@ class MeshDataHandlerImpl( private val storeForwardHandler: StoreForwardPacketHandler, private val telemetryHandler: TelemetryPacketHandler, private val adminPacketHandler: AdminPacketHandler, - @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshDataHandler { + private lateinit var scope: CoroutineScope + + override fun start(scope: CoroutineScope) { + this.scope = scope + storeForwardHandler.start(scope) + telemetryHandler.start(scope) + } private val rememberDataType = setOf( @@ -248,7 +252,7 @@ class MeshDataHandlerImpl( val payload = packet.decoded?.payload ?: return val u = User.ADAPTER.decode(payload) - .let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } + .let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it } .let { if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) { it.copy(long_name = "${it.long_name} (MQTT)") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index d9d21ad8b..9fd28ecb4 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis @@ -32,8 +31,6 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.isLora -import org.meshtastic.core.model.util.toOneLineString -import org.meshtastic.core.model.util.toPIIString import org.meshtastic.core.repository.FromRadioPacketHandler import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.MeshMessageProcessor @@ -56,8 +53,8 @@ class MeshMessageProcessorImpl( private val meshLogRepository: Lazy, private val router: Lazy, private val fromRadioDispatcher: FromRadioPacketHandler, - @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshMessageProcessor { + private lateinit var scope: CoroutineScope private val mapsMutex = Mutex() private val logUuidByPacketId = mutableMapOf() @@ -71,14 +68,15 @@ class MeshMessageProcessorImpl( @Volatile private var lastLocalNodeRefreshMs = 0L private val earlyMutex = Mutex() - private val earlyReceivedPackets = ArrayDeque() + private val earlyReceivedPackets = kotlin.collections.ArrayDeque() private val maxEarlyPacketBuffer = 10240 override fun clearEarlyPackets() { scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } } } - init { + override fun start(scope: CoroutineScope) { + this.scope = scope nodeManager.isNodeDbReady .onEach { ready -> if (ready) { @@ -98,7 +96,7 @@ class MeshMessageProcessorImpl( } .onFailure { _ -> Logger.e(primaryException) { - "Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord." + "Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord." } } } @@ -127,11 +125,11 @@ class MeshMessageProcessorImpl( proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString() proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString() proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString() - proto.my_info != null -> "MyInfo" to proto.my_info!!.toOneLineString() - proto.node_info != null -> "NodeInfo" to proto.node_info!!.toPIIString() - proto.config != null -> "Config" to proto.config!!.toOneLineString() - proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig!!.toOneLineString() - proto.channel != null -> "Channel" to proto.channel!!.toOneLineString() + proto.my_info != null -> "MyInfo" to proto.my_info.toString() + proto.node_info != null -> "NodeInfo" to proto.node_info.toString() + proto.config != null -> "Config" to proto.config.toString() + proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString() + proto.channel != null -> "Channel" to proto.channel.toString() proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString() else -> return } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt index 8973589bd..aaf109be9 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.data.manager +import kotlinx.coroutines.CoroutineScope import org.koin.core.annotation.Single import org.meshtastic.core.repository.MeshActionHandler import org.meshtastic.core.repository.MeshConfigFlowManager @@ -63,4 +64,13 @@ class MeshRouterImpl( override val xmodemManager: XModemManager get() = xmodemManagerLazy.value + + override fun start(scope: CoroutineScope) { + dataHandler.start(scope) + configHandler.start(scope) + tracerouteHandler.start(scope) + neighborInfoHandler.start(scope) + configFlowManager.start(scope) + actionHandler.start(scope) + } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt index 5693d343b..969b67a2f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt @@ -20,28 +20,14 @@ import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.meshtastic.core.model.MqttConnectionState -import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.network.repository.MQTTRepository -import org.meshtastic.core.network.repository.resolveEndpoint import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.mqtt.ConnectionState -import org.meshtastic.mqtt.MqttClient -import org.meshtastic.mqtt.MqttException -import org.meshtastic.mqtt.ProbeResult -import org.meshtastic.mqtt.probe import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.proto.ToRadio @@ -50,33 +36,22 @@ class MqttManagerImpl( private val mqttRepository: MQTTRepository, private val packetHandler: PacketHandler, private val serviceRepository: ServiceRepository, - @Named("ServiceScope") private val scope: CoroutineScope, ) : MqttManager { + private lateinit var scope: CoroutineScope private var mqttMessageFlow: Job? = null - private val proxyActive = MutableStateFlow(false) - override val mqttConnectionState: StateFlow = - combine(proxyActive, mqttRepository.connectionState) { active, libState -> - if (!active) MqttConnectionState.Inactive else libState.toAppState() - } - .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.Inactive) - - override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) { + override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) { + this.scope = scope if (mqttMessageFlow?.isActive == true) return if (enabled && proxyToClientEnabled) { - proxyActive.value = true mqttMessageFlow = mqttRepository.proxyMessageFlow .onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) } .catch { throwable -> - proxyActive.value = false - val message = - when (throwable) { - is MqttException.ConnectionRejected -> "MQTT: connection rejected (check credentials)" - is MqttException.ConnectionLost -> "MQTT: connection lost" - else -> "MQTT proxy failed: ${throwable.message}" - } - serviceRepository.setErrorMessage(text = message, severity = Severity.Warn) + serviceRepository.setErrorMessage( + text = "MqttClientProxy failed: $throwable", + severity = Severity.Warn, + ) } .launchIn(scope) } @@ -88,7 +63,6 @@ class MqttManagerImpl( mqttMessageFlow?.cancel() mqttMessageFlow = null } - proxyActive.value = false } override fun handleMqttProxyMessage(message: MqttClientProxyMessage) { @@ -105,57 +79,4 @@ class MqttManagerImpl( else -> {} } } - - private fun ConnectionState.toAppState(): MqttConnectionState = when (this) { - is ConnectionState.Connecting -> MqttConnectionState.Connecting - is ConnectionState.Connected -> MqttConnectionState.Connected - is ConnectionState.Reconnecting -> - MqttConnectionState.Reconnecting(attempt = attempt, lastError = lastError?.message) - is ConnectionState.Disconnected -> - reason?.let { MqttConnectionState.Disconnected(reason = it.message) } - ?: MqttConnectionState.Disconnected.Idle - } - - override suspend fun probe( - address: String, - tlsEnabled: Boolean, - username: String?, - password: String?, - ): MqttProbeStatus { - val endpoint = resolveEndpoint(address, tlsEnabled) - val result = - MqttClient.probe(endpoint = endpoint) { - val user = username?.takeUnless { it.isEmpty() } - val pass = password?.takeUnless { it.isEmpty() } - if (user != null) this.username = user - if (pass != null) password(pass) - } - return result.toAppStatus() - } - - private fun ProbeResult.toAppStatus(): MqttProbeStatus = when (this) { - is ProbeResult.Success -> { - val info = serverInfo - val summary = - buildList { - info.assignedClientIdentifier?.let { add("client=$it") } - info.maximumQosOrdinal?.let { add("maxQoS=$it") } - info.serverKeepAliveSeconds?.let { add("keepalive=${it}s") } - } - .joinToString(", ") - .ifEmpty { null } - MqttProbeStatus.Success(serverInfo = summary) - } - is ProbeResult.Rejected -> - MqttProbeStatus.Rejected( - reasonCode = reasonCode.value, - reason = message, - serverReference = serverReference, - ) - is ProbeResult.DnsFailure -> MqttProbeStatus.DnsFailure(message = cause.message) - is ProbeResult.TcpFailure -> MqttProbeStatus.TcpFailure(message = cause.message) - is ProbeResult.TlsFailure -> MqttProbeStatus.TlsFailure(message = cause.message) - is ProbeResult.Timeout -> MqttProbeStatus.Timeout(timeoutMs = durationMs) - is ProbeResult.Other -> MqttProbeStatus.Other(message = cause.message) - } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 3f483ba25..4019e5a9b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic import kotlinx.atomicfu.update import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.CoroutineScope import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis @@ -36,11 +37,16 @@ class NeighborInfoHandlerImpl( private val serviceRepository: ServiceRepository, private val serviceBroadcasts: ServiceBroadcasts, ) : NeighborInfoHandler { + private lateinit var scope: CoroutineScope private val startTimes = atomic(persistentMapOf()) override var lastNeighborInfo: NeighborInfo? = null + override fun start(scope: CoroutineScope) { + this.scope = scope + } + override fun recordStartTime(requestId: Int) { startTimes.update { it.put(requestId, nowMillis) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index fe6d22f4c..9ce4ba05d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import okio.ByteString -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.DataPacket @@ -60,8 +59,8 @@ class NodeManagerImpl( private val nodeRepository: NodeRepository, private val serviceBroadcasts: ServiceBroadcasts, private val notificationManager: NotificationManager, - @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeManager { + private lateinit var scope: CoroutineScope private val _nodeDBbyNodeNum = atomic(persistentMapOf()) private val _nodeDBbyID = atomic(persistentMapOf()) @@ -89,6 +88,10 @@ class NodeManagerImpl( myNodeNum.value = num } + override fun start(scope: CoroutineScope) { + this.scope = scope + } + companion object { private const val TIME_MS_TO_S = 1000L } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt index e2e9a8432..1d4d11adc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -22,15 +22,12 @@ 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 import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis @@ -63,7 +60,6 @@ class PacketHandlerImpl( private val radioInterfaceService: RadioInterfaceService, private val meshLogRepository: Lazy, private val serviceRepository: ServiceRepository, - @Named("ServiceScope") private val scope: CoroutineScope, ) : PacketHandler { companion object { @@ -71,15 +67,11 @@ class PacketHandlerImpl( } private var queueJob: Job? = null + private lateinit var scope: CoroutineScope private val queueMutex = Mutex() private val queuedPackets = mutableListOf() - // Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket) - // calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and - // a single consumer coroutine enqueues packets under queueMutex in arrival order. - private val outboundChannel = Channel(Channel.UNLIMITED) - // Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked() // and the queue processor's finally block to prevent restarting a stopped queue. private var queueStopped = false @@ -87,18 +79,9 @@ class PacketHandlerImpl( private val responseMutex = Mutex() private val queueResponse = mutableMapOf>() - init { - // Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket) - // entry point, preserving FIFO across rapid concurrent callers. - scope.launch { - outboundChannel.consumeAsFlow().collect { packet -> - queueMutex.withLock { - queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. - queuedPackets.add(packet) - startPacketQueueLocked() - } - } - } + override fun start(scope: CoroutineScope) { + this.scope = scope + queueStopped = false // Safe: called before any concurrent operations on this scope. } override fun sendToRadio(p: ToRadio) { @@ -125,9 +108,13 @@ class PacketHandlerImpl( } override fun sendToRadio(packet: MeshPacket) { - // 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) + scope.launch { + queueMutex.withLock { + queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. + queuedPackets.add(packet) + startPacketQueueLocked() + } + } } @Suppress("TooGenericExceptionCaught", "SwallowedException") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index e8ab4eeb7..4f71879ce 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import okio.ByteString.Companion.toByteString import okio.IOException -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.DataPacket @@ -46,8 +45,12 @@ class StoreForwardPacketHandlerImpl( private val serviceBroadcasts: ServiceBroadcasts, private val historyManager: HistoryManager, private val dataHandler: Lazy, - @Named("ServiceScope") private val scope: CoroutineScope, ) : StoreForwardPacketHandler { + private lateinit var scope: CoroutineScope + + override fun start(scope: CoroutineScope) { + this.scope = scope + } override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { val payload = packet.decoded?.payload ?: return diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt index 4887ff19b..205dd30e2 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket @@ -50,12 +49,16 @@ class TelemetryPacketHandlerImpl( private val nodeManager: NodeManager, private val connectionManager: Lazy, private val notificationManager: NotificationManager, - @Named("ServiceScope") private val scope: CoroutineScope, ) : TelemetryPacketHandler { + private lateinit var scope: CoroutineScope private val batteryMutex = Mutex() private val batteryPercentCooldowns = mutableMapOf() + override fun start(scope: CoroutineScope) { + this.scope = scope + } + @Suppress("LongMethod", "CyclomaticComplexMethod") override fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { val payload = packet.decoded?.payload ?: return diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index 5d2feb65e..5e8d954f6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -22,7 +22,6 @@ import kotlinx.atomicfu.update import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.handledLaunch @@ -43,11 +42,15 @@ class TracerouteHandlerImpl( private val serviceRepository: ServiceRepository, private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val nodeRepository: NodeRepository, - @Named("ServiceScope") private val scope: CoroutineScope, ) : TracerouteHandler { + private lateinit var scope: CoroutineScope private val startTimes = atomic(persistentMapOf()) + override fun start(scope: CoroutineScope) { + this.scope = scope + } + override fun recordStartTime(requestId: Int) { startTimes.update { it.put(requestId, nowMillis) } } @@ -65,7 +68,7 @@ class TracerouteHandlerImpl( routeDiscovery.getTracerouteResponse( getUser = { num -> nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" } - ?: "Unknown" + ?: "Unknown" // TODO: Use core:resources once available in core:data }, headerTowards = "Route towards destination:", headerBack = "Route back to us:", diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index fdcc6d344..338a0d6ea 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource @@ -99,7 +98,7 @@ class DeviceHardwareRepositoryImpl( } // 2. Fetch from remote API - safeCatching { + runCatching { Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" } val remoteHardware = remoteDataSource.getAllDeviceHardware() Logger.d { @@ -158,7 +157,7 @@ class DeviceHardwareRepositoryImpl( hwModel: Int, target: String?, quirks: List, - ): Result = safeCatching { + ): Result = runCatching { Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" } val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset() Logger.d { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt index 8f3154815..a47a5381f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource import org.meshtastic.core.database.entity.FirmwareRelease @@ -98,7 +97,7 @@ open class FirmwareReleaseRepositoryImpl( */ private suspend fun updateCacheFromSources() { val remoteFetchSuccess = - safeCatching { + runCatching { Logger.d { "Fetching fresh firmware releases from remote API." } val networkReleases = remoteDataSource.getFirmwareReleases() @@ -111,7 +110,7 @@ open class FirmwareReleaseRepositoryImpl( // If remote fetch failed, try the JSON fallback as a last resort. if (!remoteFetchSuccess) { Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." } - safeCatching { + runCatching { val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset() localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE) localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index 149c62d2b..f6a49f190 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -28,8 +28,6 @@ import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.database.DatabaseProvider -import org.meshtastic.core.database.dao.NodeInfoDao -import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.database.entity.toReaction import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ContactSettings @@ -110,7 +108,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val dao.upsertContactSettings(listOf(updated)) } - override suspend fun getQueuedPackets(): List = + override suspend fun getQueuedPackets(): List? = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() } suspend fun insertRoomPacket(packet: RoomPacket) = @@ -156,14 +154,13 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val else -> dao.getMessagesFrom(contact) } flow.mapLatest { packets -> - val cachedGetNode = memoize(getNode) - val replyIds = packets.mapNotNull { it.packet.data.replyId?.takeIf { id -> id != 0 } }.distinct() - val replyMap = batchGetPacketsByIds(replyIds) packets.map { packet -> - val message = packet.toMessage(cachedGetNode) - val replyId = message.replyId?.takeIf { it != 0 } - val originalMessage = replyId?.let { replyMap[it] }?.toMessage(cachedGetNode) - if (originalMessage != null) message.copy(originalMessage = originalMessage) else message + val message = packet.toMessage(getNode) + message.replyId + .takeIf { it != null && it != 0 } + ?.let { getPacketByPacketIdInternal(it) } + ?.let { originalPacket -> originalPacket.toMessage(getNode) } + ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message } } } @@ -180,16 +177,13 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val ) .flow .map { pagingData -> - val cachedGetNode = memoize(getNode) - val replyCache = mutableMapOf() pagingData.map { packet -> - val message = packet.toMessage(cachedGetNode) - val replyId = message.replyId?.takeIf { it != 0 } - val originalMessage = - replyId - ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } } - ?.toMessage(cachedGetNode) - if (originalMessage != null) message.copy(originalMessage = originalMessage) else message + val message = packet.toMessage(getNode) + message.replyId + .takeIf { it != null && it != 0 } + ?.let { getPacketByPacketIdInternal(it) } + ?.let { originalPacket -> originalPacket.toMessage(getNode) } + ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message } } @@ -210,16 +204,13 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val ) .flow .map { pagingData -> - val cachedGetNode = memoize(getNode) - val replyCache = mutableMapOf() pagingData.map { packet -> - val message = packet.toMessage(cachedGetNode) - val replyId = message.replyId?.takeIf { it != 0 } - val originalMessage = - replyId - ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } } - ?.toMessage(cachedGetNode) - if (originalMessage != null) message.copy(originalMessage = originalMessage) else message + val message = packet.toMessage(getNode) + message.replyId + .takeIf { it != null && it != 0 } + ?.let { getPacketByPacketIdInternal(it) } + ?.let { originalPacket -> originalPacket.toMessage(getNode) } + ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message } } @@ -239,22 +230,6 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val private suspend fun getPacketByPacketIdInternal(packetId: Int) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) } - private suspend fun batchGetPacketsByIds(ids: List): Map = if (ids.isEmpty()) { - emptyMap() - } else { - withContext(dispatchers.io) { - val dao = dbManager.currentDb.value.packetDao() - ids.chunked(NodeInfoDao.MAX_BIND_PARAMS) - .flatMap { dao.getPacketsByPacketIds(it) } - .associateBy { it.packet.packetId } - } - } - - private fun memoize(getNode: suspend (String?) -> Node): suspend (String?) -> Node { - val cache = mutableMapOf() - return { id -> cache.getOrPut(id) { getNode(id) } } - } - override suspend fun insert( packet: DataPacket, myNodeNum: Int, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt index 5b29e9f26..6ac094e48 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt @@ -25,7 +25,6 @@ import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verify.VerifyMode.Companion.not import dev.mokkery.verifySuspend -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -47,7 +46,6 @@ 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 @@ -69,7 +67,6 @@ class MeshActionHandlerImplTest { private val dataHandler = mock(MockMode.autofill) private val analytics = mock(MockMode.autofill) private val meshPrefs = mock(MockMode.autofill) - private val uiPrefs = mock(MockMode.autofill) private val databaseManager = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) private val messageProcessor = mock(MockMode.autofill) @@ -92,29 +89,28 @@ class MeshActionHandlerImplTest { every { nodeManager.myNodeNum } returns myNodeNumFlow every { nodeManager.getMyId() } returns "!12345678" every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - } - private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl( - nodeManager = nodeManager, - commandSender = commandSender, - packetRepository = lazy { packetRepository }, - serviceBroadcasts = serviceBroadcasts, - dataHandler = lazy { dataHandler }, - analytics = analytics, - meshPrefs = meshPrefs, - uiPrefs = uiPrefs, - databaseManager = databaseManager, - notificationManager = notificationManager, - messageProcessor = lazy { messageProcessor }, - radioConfigRepository = radioConfigRepository, - scope = scope, - ) + handler = + MeshActionHandlerImpl( + nodeManager = nodeManager, + commandSender = commandSender, + packetRepository = lazy { packetRepository }, + serviceBroadcasts = serviceBroadcasts, + dataHandler = lazy { dataHandler }, + analytics = analytics, + meshPrefs = meshPrefs, + databaseManager = databaseManager, + notificationManager = notificationManager, + messageProcessor = lazy { messageProcessor }, + radioConfigRepository = radioConfigRepository, + ) + } // ---- handleUpdateLastAddress (device-switch path — P0 critical) ---- @Test fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -132,7 +128,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr") @@ -145,7 +141,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -160,7 +156,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow(null) @@ -172,7 +168,7 @@ class MeshActionHandlerImplTest { @Test fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old") everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit @@ -191,7 +187,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) myNodeNumFlow.value = null val node = createTestNode(REMOTE_NODE_NUM) @@ -205,7 +201,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false) handler.onServiceAction(ServiceAction.Favorite(node)) @@ -217,7 +213,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true) handler.onServiceAction(ServiceAction.Favorite(node)) @@ -231,7 +227,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false) handler.onServiceAction(ServiceAction.Ignore(node)) @@ -246,7 +242,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val node = createTestNode(REMOTE_NODE_NUM, isMuted = false) handler.onServiceAction(ServiceAction.Mute(node)) @@ -260,7 +256,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM)) advanceUntilIdle() @@ -272,7 +268,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true val action = ServiceAction.SendContact(SharedContact()) @@ -285,7 +281,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false val action = ServiceAction.SendContact(SharedContact()) @@ -300,7 +296,7 @@ class MeshActionHandlerImplTest { @Test fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val contact = SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser")) @@ -315,7 +311,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetOwner_sendsAdminAndUpdatesLocalNode() { - handler = createHandler(testScope) + handler.start(testScope) val meshUser = MeshUser( id = "!12345678", @@ -335,7 +331,7 @@ class MeshActionHandlerImplTest { @Test fun handleSend_sendsDataAndBroadcastsStatus() { - handler = createHandler(testScope) + handler.start(testScope) val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0) handler.handleSend(packet, MY_NODE_NUM) @@ -349,7 +345,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_sameNode_doesNothing() { - handler = createHandler(testScope) + handler.start(testScope) handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM) @@ -358,8 +354,8 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) + handler.start(testScope) + every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) val validPosition = Position(37.7749, -122.4194, 10) handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) @@ -369,8 +365,8 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) + handler.start(testScope) + every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) every { nodeManager.nodeDBbyNodeNum } returns emptyMap() val invalidPosition = Position(0.0, 0.0, 0) @@ -382,8 +378,8 @@ class MeshActionHandlerImplTest { @Test fun handleRequestPosition_doNotProvide_sendsZeroPosition() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) + handler.start(testScope) + every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) val validPosition = Position(37.7749, -122.4194, 10) handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) @@ -396,7 +392,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit val config = Config(lora = Config.LoRaConfig(hop_limit = 5)) @@ -413,7 +409,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) myNodeNumFlow.value = MY_NODE_NUM everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit @@ -429,7 +425,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) myNodeNumFlow.value = MY_NODE_NUM val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) @@ -446,7 +442,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit val channel = Channel(index = 1) @@ -461,7 +457,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetChannel_nullPayload_doesNothing() { - handler = createHandler(testScope) + handler.start(testScope) handler.handleSetChannel(null, MY_NODE_NUM) @@ -472,7 +468,7 @@ class MeshActionHandlerImplTest { @Test fun handleRemoveByNodenum_removesAndSendsAdmin() { - handler = createHandler(testScope) + handler.start(testScope) handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM) @@ -484,7 +480,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteOwner_decodesAndSendsAdmin() { - handler = createHandler(testScope) + handler.start(testScope) val user = User(id = "!remote01", long_name = "Remote", short_name = "RM") val payload = User.ADAPTER.encode(user) @@ -499,7 +495,7 @@ class MeshActionHandlerImplTest { @Test fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() { - handler = createHandler(testScope) + handler.start(testScope) handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) @@ -508,7 +504,7 @@ class MeshActionHandlerImplTest { @Test fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() { - handler = createHandler(testScope) + handler.start(testScope) handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value) @@ -519,7 +515,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteChannel_nullPayload_doesNothing() { - handler = createHandler(testScope) + handler.start(testScope) handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null) @@ -528,7 +524,7 @@ class MeshActionHandlerImplTest { @Test fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() { - handler = createHandler(testScope) + handler.start(testScope) val channel = Channel(index = 2) val payload = Channel.ADAPTER.encode(channel) @@ -542,7 +538,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestRebootOta_withNullHash_sendsAdmin() { - handler = createHandler(testScope) + handler.start(testScope) handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null) @@ -551,7 +547,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestRebootOta_withHash_sendsAdmin() { - handler = createHandler(testScope) + handler.start(testScope) val hash = byteArrayOf(0x01, 0x02, 0x03) handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash) @@ -563,7 +559,7 @@ class MeshActionHandlerImplTest { @Test fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() { - handler = createHandler(testScope) + handler.start(testScope) handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt index fdcd8ed44..9580d5363 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode -import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any @@ -98,9 +97,9 @@ class MeshConfigFlowManagerImplTest { serviceBroadcasts = serviceBroadcasts, analytics = analytics, commandSender = commandSender, - heartbeatSender = DataLayerHeartbeatSender(packetHandler), - scope = testScope, + packetHandler = packetHandler, ) + manager.start(testScope) } // ---------- handleMyInfo ---------- @@ -175,49 +174,6 @@ class MeshConfigFlowManagerImplTest { verify { connectionManager.startNodeInfoOnly() } } - @Test - fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - sentPackets.clear() // Clear any packets from prior phases - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - val heartbeats = sentPackets.filter { it.heartbeat != null } - assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat") - assertEquals( - true, - heartbeats[0].heartbeat!!.nonce != 0, - "Inter-stage heartbeat should have a non-zero nonce", - ) - } - - @Test - fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest { - val oldMetadata = - DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false) - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(oldMetadata) - advanceUntilIdle() - - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // Handshake should still progress despite old firmware - verify { connectionManager.onRadioConfigLoaded() } - verify { connectionManager.startNodeInfoOnly() } - } - @Test fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest { manager.handleMyInfo(protoMyNodeInfo) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt index bf3247815..b71942d0e 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt @@ -23,7 +23,6 @@ import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verifySuspend -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -61,20 +60,20 @@ class MeshConfigHandlerImplTest { fun setUp() { every { radioConfigRepository.localConfigFlow } returns localConfigFlow every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow - } - private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl( - radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, - nodeManager = nodeManager, - scope = scope, - ) + handler = + MeshConfigHandlerImpl( + radioConfigRepository = radioConfigRepository, + serviceRepository = serviceRepository, + nodeManager = nodeManager, + ) + } // ---------- start and flow wiring ---------- @Test fun `start wires localConfig flow from repository`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) localConfigFlow.value = config advanceUntilIdle() @@ -84,7 +83,7 @@ class MeshConfigHandlerImplTest { @Test fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) moduleConfigFlow.value = config advanceUntilIdle() @@ -96,7 +95,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) handler.handleDeviceConfig(config) advanceUntilIdle() @@ -107,7 +106,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val configs = listOf( Config(position = Config.PositionConfig()), @@ -132,7 +131,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) handler.handleModuleConfig(config) advanceUntilIdle() @@ -143,7 +142,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val myNum = 123 every { nodeManager.myNodeNum } returns MutableStateFlow(myNum) @@ -156,7 +155,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) every { nodeManager.myNodeNum } returns MutableStateFlow(null) val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) @@ -169,7 +168,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel persists channel settings`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val channel = Channel(index = 0) handler.handleChannel(channel) advanceUntilIdle() @@ -179,7 +178,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) every { nodeManager.getMyNodeInfo() } returns MyNodeInfo( myNodeNum = 123, @@ -207,7 +206,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) every { nodeManager.getMyNodeInfo() } returns null val channel = Channel(index = 0) @@ -221,7 +220,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) + handler.start(backgroundScope) val config = DeviceUIConfig() handler.handleDeviceUIConfig(config) advanceUntilIdle() diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index 07c8914ad..5263254d3 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -24,10 +24,9 @@ import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -61,7 +60,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class) class MeshConnectionManagerImplTest { private val radioInterfaceService = mock(MockMode.autofill) private val serviceRepository = mock(MockMode.autofill) @@ -109,35 +108,37 @@ class MeshConnectionManagerImplTest { every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() every { packetHandler.sendToRadio(any()) } returns Unit + + manager = + MeshConnectionManagerImpl( + radioInterfaceService, + serviceRepository, + serviceBroadcasts, + serviceNotifications, + uiPrefs, + packetHandler, + nodeRepository, + locationManager, + mqttManager, + historyManager, + radioConfigRepository, + commandSender, + nodeManager, + analytics, + packetRepository, + workerManager, + appWidgetUpdater, + ) } - private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl( - radioInterfaceService, - serviceRepository, - serviceBroadcasts, - serviceNotifications, - uiPrefs, - packetHandler, - nodeRepository, - locationManager, - mqttManager, - historyManager, - radioConfigRepository, - commandSender, - nodeManager, - analytics, - packetRepository, - workerManager, - appWidgetUpdater, - DataLayerHeartbeatSender(packetHandler), - scope, - ) - - @AfterTest fun tearDown() = Unit + @AfterTest fun tearDown() {} @Test fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) { - manager = createManager(backgroundScope) + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + + manager.start(backgroundScope) radioConnectionState.value = ConnectionState.Connected advanceUntilIdle() @@ -149,63 +150,20 @@ class MeshConnectionManagerImplTest { verify { serviceBroadcasts.broadcastConnection() } } - @Test - fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - - manager = createManager(backgroundScope) - radioConnectionState.value = ConnectionState.Connected - // Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout - advanceTimeBy(200) - - // First ToRadio should be a heartbeat, second should be want_config_id - assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets") - val heartbeat = sentPackets[0] - val wantConfig = sentPackets[1] - - assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat") - assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce") - assertEquals( - org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE, - wantConfig.want_config_id, - "Second packet should be want_config_id with CONFIG_NONCE", - ) - } - - @Test - fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - manager = createManager(backgroundScope) - radioConnectionState.value = ConnectionState.Connected - // Advance only 50ms — within the 100ms settle window - advanceTimeBy(50) - - // Should have sent only the heartbeat so far, not want_config_id - assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes") - - // Disconnect before the settle delay completes — should cancel the pending config start - radioConnectionState.value = ConnectionState.Disconnected - advanceTimeBy(200) - - // The want_config_id should NOT have been sent because the job was cancelled - val configPackets = sentPackets.filter { it.want_config_id != null } - assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect") - } - @Test fun `Disconnected state stops services`() = runTest(testDispatcher) { + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + every { packetHandler.stopPacketQueue() } returns Unit + every { locationManager.stop() } returns Unit + every { mqttManager.stop() } returns Unit + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + every { packetHandler.stopPacketQueue() } returns Unit + every { locationManager.stop() } returns Unit + every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - manager = createManager(backgroundScope) + manager.start(backgroundScope) // Transition to Connected first so that Disconnected actually does something radioConnectionState.value = ConnectionState.Connected advanceUntilIdle() @@ -232,9 +190,14 @@ class MeshConnectionManagerImplTest { device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT), ) every { radioConfigRepository.localConfigFlow } returns flowOf(config) + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + every { packetHandler.stopPacketQueue() } returns Unit + every { locationManager.stop() } returns Unit + every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - manager = createManager(backgroundScope) + manager.start(backgroundScope) advanceUntilIdle() radioConnectionState.value = ConnectionState.DeviceSleep @@ -252,8 +215,13 @@ class MeshConnectionManagerImplTest { // Power saving enabled val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) every { radioConfigRepository.localConfigFlow } returns flowOf(config) + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + every { packetHandler.stopPacketQueue() } returns Unit + every { locationManager.stop() } returns Unit + every { mqttManager.stop() } returns Unit - manager = createManager(backgroundScope) + manager.start(backgroundScope) advanceUntilIdle() radioConnectionState.value = ConnectionState.DeviceSleep @@ -268,7 +236,7 @@ class MeshConnectionManagerImplTest { @Test fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) { - manager = createManager(backgroundScope) + manager.start(backgroundScope) val packetId = 456 everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket) every { workerManager.enqueueSendMessage(any()) } returns Unit @@ -289,15 +257,15 @@ class MeshConnectionManagerImplTest { moduleConfigFlow.value = moduleConfig every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit every { nodeManager.myNodeNum } returns MutableStateFlow(123) - every { mqttManager.startProxy(any(), any()) } returns Unit + every { mqttManager.start(any(), any(), any()) } returns Unit every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit every { nodeManager.getMyNodeInfo() } returns null - manager = createManager(backgroundScope) + manager.start(backgroundScope) manager.onNodeDbReady() advanceUntilIdle() - verify { mqttManager.startProxy(true, true) } + verify { mqttManager.start(any(), true, true) } verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) } } @@ -311,9 +279,14 @@ class MeshConnectionManagerImplTest { device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER), ) every { radioConfigRepository.localConfigFlow } returns flowOf(config) + every { packetHandler.sendToRadio(any()) } returns Unit + every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit + every { packetHandler.stopPacketQueue() } returns Unit + every { locationManager.stop() } returns Unit + every { mqttManager.stop() } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - manager = createManager(backgroundScope) + manager.start(backgroundScope) advanceUntilIdle() // Transition to Connected then DeviceSleep @@ -337,92 +310,4 @@ class MeshConnectionManagerImplTest { "Should transition to Disconnected after capped timeout (300s), not the raw 3630s", ) } - - @Test - fun `rapid state transitions are serialized by connectionMutex`() = runTest(testDispatcher) { - // Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected) - val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - // Record every state transition so we can verify ordering - val observed = mutableListOf() - every { serviceRepository.setConnectionState(any()) } calls - { call -> - val state = call.arg(0) - observed.add(state) - connectionStateFlow.value = state - } - - manager = createManager(backgroundScope) - advanceUntilIdle() - - // Rapid-fire: Connected -> DeviceSleep -> Disconnected without yielding between them. - // Without the Mutex, the intermediate DeviceSleep could be missed or applied out of order. - radioConnectionState.value = ConnectionState.Connected - radioConnectionState.value = ConnectionState.DeviceSleep - radioConnectionState.value = ConnectionState.Disconnected - advanceUntilIdle() - - // Verify final state - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "Final state should be Disconnected after rapid transitions", - ) - - // Verify that all intermediate states were observed in correct order. - // Connected triggers handleConnected() which sets Connecting (handshake start), - // then DeviceSleep, then Disconnected. - assertEquals( - listOf(ConnectionState.Connecting, ConnectionState.DeviceSleep, ConnectionState.Disconnected), - observed, - "State transitions should be serialized in order: Connecting -> DeviceSleep -> Disconnected", - ) - } - - @Test - fun `concurrent sleep-timeout and radio state change are serialized`() { - val standardDispatcher = StandardTestDispatcher() - runTest(standardDispatcher) { - // Power saving enabled with a short ls_secs so the sleep timeout fires quickly - val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1)) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - val observed = mutableListOf() - every { serviceRepository.setConnectionState(any()) } calls - { call -> - val state = call.arg(0) - observed.add(state) - connectionStateFlow.value = state - } - - manager = createManager(backgroundScope) - advanceUntilIdle() - - // Transition to Connected -> DeviceSleep to start the sleep timer - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - observed.clear() - - // Before the sleep timeout fires, emit Connected from the radio (simulating device - // waking up). Then let the timeout fire. The mutex ensures they don't race. - radioConnectionState.value = ConnectionState.Connected - // Advance past the sleep timeout (ls_secs=1 + 30s base = 31s) - advanceTimeBy(32_000L) - advanceUntilIdle() - - // The Connected transition should have cancelled the sleep timeout, so we should - // end up in Connecting (from handleConnected), NOT Disconnected (from timeout). - assertEquals( - ConnectionState.Connecting, - serviceRepository.connectionState.value, - "Connected should cancel the sleep timeout; final state should be Connecting", - ) - } - } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 022608be1..5f738b439 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -108,8 +108,8 @@ class MeshDataHandlerTest { storeForwardHandler = storeForwardHandler, telemetryHandler = telemetryHandler, adminPacketHandler = adminPacketHandler, - scope = testScope, ) + handler.start(testScope) // Default: mapper returns null for empty packets, which is the safe default every { dataMapper.toDataPacket(any()) } returns null diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt index 251aefabe..3090cf49e 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt @@ -23,7 +23,6 @@ import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verifySuspend -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -66,22 +65,22 @@ class MeshMessageProcessorImplTest { every { nodeManager.isNodeDbReady } returns isNodeDbReady every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) every { router.dataHandler } returns dataHandler - } - private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl( - nodeManager = nodeManager, - serviceRepository = serviceRepository, - meshLogRepository = lazy { meshLogRepository }, - router = lazy { router }, - fromRadioDispatcher = fromRadioDispatcher, - scope = scope, - ) + processor = + MeshMessageProcessorImpl( + nodeManager = nodeManager, + serviceRepository = serviceRepository, + meshLogRepository = lazy { meshLogRepository }, + router = lazy { router }, + fromRadioDispatcher = fromRadioDispatcher, + ) + } // ---------- handleFromRadio: non-packet variants ---------- @Test fun `handleFromRadio dispatches non-packet variants to fromRadioDispatcher`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) val logRecord = LogRecord(message = "test log") val fromRadio = FromRadio(log_record = logRecord) val bytes = FromRadio.ADAPTER.encode(fromRadio) @@ -94,7 +93,7 @@ class MeshMessageProcessorImplTest { @Test fun `handleFromRadio falls back to LogRecord parsing when FromRadio fails`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) // Encode a raw LogRecord (not wrapped in FromRadio) — first decode as FromRadio fails, // fallback decode as LogRecord succeeds val logRecord = LogRecord(message = "fallback log") @@ -109,7 +108,7 @@ class MeshMessageProcessorImplTest { @Test fun `handleFromRadio with completely invalid bytes does not crash`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) // Invalid protobuf bytes — both parses should fail val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte()) @@ -122,7 +121,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets are buffered when node DB is not ready`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = false val packet = @@ -142,7 +141,7 @@ class MeshMessageProcessorImplTest { @Test fun `buffered packets are flushed when node DB becomes ready`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = false val packet = @@ -166,7 +165,7 @@ class MeshMessageProcessorImplTest { @Test fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = false // The maxEarlyPacketBuffer is 10240 — we won't actually fill it in this test, @@ -196,7 +195,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets with rx_time 0 get current time`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = true val packet = @@ -215,7 +214,7 @@ class MeshMessageProcessorImplTest { @Test fun `packets with non-zero rx_time keep their time`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = true val packet = @@ -236,7 +235,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket updates myNode lastHeard`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = true val packet = @@ -256,7 +255,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket updates sender node`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = true val senderNode = 999 @@ -280,7 +279,7 @@ class MeshMessageProcessorImplTest { @Test fun `packet with null decoded is skipped`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = true val packet = MeshPacket(id = 1, from = 999, decoded = null) @@ -294,7 +293,7 @@ class MeshMessageProcessorImplTest { @Test fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = true val packet = @@ -316,7 +315,7 @@ class MeshMessageProcessorImplTest { @Test fun `clearEarlyPackets empties the buffer`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) isNodeDbReady.value = false val packet = @@ -343,7 +342,7 @@ class MeshMessageProcessorImplTest { @Test fun `FromRadio log_record variant is logged as MeshLog`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) + processor.start(backgroundScope) val logRecord = LogRecord(message = "device log") val fromRadio = FromRadio(log_record = logRecord) val bytes = FromRadio.ADAPTER.encode(fromRadio) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 509066867..022590467 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode import dev.mokkery.mock -import kotlinx.coroutines.test.TestScope import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket @@ -45,13 +44,12 @@ class NodeManagerImplTest { private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill) private val notificationManager: NotificationManager = mock(MockMode.autofill) - private val testScope = TestScope() private lateinit var nodeManager: NodeManagerImpl @BeforeTest fun setUp() { - nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope) + nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager) } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt index e0bda6075..fe89063ef 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt @@ -21,7 +21,6 @@ import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock -import dev.mokkery.verify import dev.mokkery.verifySuspend import io.kotest.property.Arb import io.kotest.property.arbitrary.int @@ -71,8 +70,8 @@ class PacketHandlerImplTest { radioInterfaceService, lazy { meshLogRepository }, serviceRepository, - testScope, ) + handler.start(testScope) } @Test @@ -85,8 +84,6 @@ class PacketHandlerImplTest { val toRadio = ToRadio(packet = MeshPacket(id = 123)) handler.sendToRadio(toRadio) - - verify { radioInterfaceService.sendToRadio(any()) } } @Test @@ -96,8 +93,6 @@ class PacketHandlerImplTest { handler.sendToRadio(packet) testScheduler.runCurrent() - - verify { radioInterfaceService.sendToRadio(any()) } } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index 900245332..e465aaa63 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -72,8 +72,8 @@ class StoreForwardPacketHandlerImplTest { serviceBroadcasts = serviceBroadcasts, historyManager = historyManager, dataHandler = lazy { dataHandler }, - scope = testScope, ) + handler.start(testScope) } private fun makeSfPacket(from: Int, sf: StoreAndForward): MeshPacket { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt index 28bf22fdc..8f295a2b6 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt @@ -62,8 +62,8 @@ class TelemetryPacketHandlerImplTest { nodeManager = nodeManager, connectionManager = lazy { connectionManager }, notificationManager = notificationManager, - scope = testScope, ) + handler.start(testScope) } private fun makeTelemetryPacket(from: Int, telemetry: Telemetry): MeshPacket { diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json deleted file mode 100644 index c26991ac4..000000000 --- a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json +++ /dev/null @@ -1,1052 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 38, - "identityHash": "ffca7655fa7c1d69fdd404b1b39d140c", - "entities": [ - { - "tableName": "my_node", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", - "fields": [ - { - "fieldPath": "myNodeNum", - "columnName": "myNodeNum", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "model", - "columnName": "model", - "affinity": "TEXT" - }, - { - "fieldPath": "firmwareVersion", - "columnName": "firmwareVersion", - "affinity": "TEXT" - }, - { - "fieldPath": "couldUpdate", - "columnName": "couldUpdate", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "shouldUpdate", - "columnName": "shouldUpdate", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "currentPacketId", - "columnName": "currentPacketId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "messageTimeoutMsec", - "columnName": "messageTimeoutMsec", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "minAppVersion", - "columnName": "minAppVersion", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "maxChannels", - "columnName": "maxChannels", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasWifi", - "columnName": "hasWifi", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "deviceId", - "columnName": "deviceId", - "affinity": "TEXT" - }, - { - "fieldPath": "pioEnv", - "columnName": "pioEnv", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "myNodeNum" - ] - } - }, - { - "tableName": "nodes", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", - "fields": [ - { - "fieldPath": "num", - "columnName": "num", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "user", - "columnName": "user", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "longName", - "columnName": "long_name", - "affinity": "TEXT" - }, - { - "fieldPath": "shortName", - "columnName": "short_name", - "affinity": "TEXT" - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "latitude", - "columnName": "latitude", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "longitude", - "columnName": "longitude", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "snr", - "columnName": "snr", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "rssi", - "columnName": "rssi", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastHeard", - "columnName": "last_heard", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "deviceTelemetry", - "columnName": "device_metrics", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "channel", - "columnName": "channel", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "viaMqtt", - "columnName": "via_mqtt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hopsAway", - "columnName": "hops_away", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isFavorite", - "columnName": "is_favorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isIgnored", - "columnName": "is_ignored", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "isMuted", - "columnName": "is_muted", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "environmentTelemetry", - "columnName": "environment_metrics", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "powerTelemetry", - "columnName": "power_metrics", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "paxcounter", - "columnName": "paxcounter", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "publicKey", - "columnName": "public_key", - "affinity": "BLOB" - }, - { - "fieldPath": "notes", - "columnName": "notes", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "''" - }, - { - "fieldPath": "manuallyVerified", - "columnName": "manually_verified", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "nodeStatus", - "columnName": "node_status", - "affinity": "TEXT" - }, - { - "fieldPath": "lastTransport", - "columnName": "last_transport", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "num" - ] - }, - "indices": [ - { - "name": "index_nodes_last_heard", - "unique": false, - "columnNames": [ - "last_heard" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" - }, - { - "name": "index_nodes_short_name", - "unique": false, - "columnNames": [ - "short_name" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" - }, - { - "name": "index_nodes_long_name", - "unique": false, - "columnNames": [ - "long_name" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" - }, - { - "name": "index_nodes_hops_away", - "unique": false, - "columnNames": [ - "hops_away" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" - }, - { - "name": "index_nodes_is_favorite", - "unique": false, - "columnNames": [ - "is_favorite" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" - }, - { - "name": "index_nodes_last_heard_is_favorite", - "unique": false, - "columnNames": [ - "last_heard", - "is_favorite" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" - }, - { - "name": "index_nodes_public_key", - "unique": false, - "columnNames": [ - "public_key" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" - } - ] - }, - { - "tableName": "packet", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "myNodeNum", - "columnName": "myNodeNum", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "port_num", - "columnName": "port_num", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "contact_key", - "columnName": "contact_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "received_time", - "columnName": "received_time", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "read", - "columnName": "read", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "1" - }, - { - "fieldPath": "data", - "columnName": "data", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "packetId", - "columnName": "packet_id", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "routingError", - "columnName": "routing_error", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "snr", - "columnName": "snr", - "affinity": "REAL", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "rssi", - "columnName": "rssi", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "hopsAway", - "columnName": "hopsAway", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "sfpp_hash", - "columnName": "sfpp_hash", - "affinity": "BLOB" - }, - { - "fieldPath": "filtered", - "columnName": "filtered", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "uuid" - ] - }, - "indices": [ - { - "name": "index_packet_myNodeNum", - "unique": false, - "columnNames": [ - "myNodeNum" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" - }, - { - "name": "index_packet_port_num", - "unique": false, - "columnNames": [ - "port_num" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" - }, - { - "name": "index_packet_contact_key", - "unique": false, - "columnNames": [ - "contact_key" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" - }, - { - "name": "index_packet_contact_key_port_num_received_time", - "unique": false, - "columnNames": [ - "contact_key", - "port_num", - "received_time" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" - }, - { - "name": "index_packet_packet_id", - "unique": false, - "columnNames": [ - "packet_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" - }, - { - "name": "index_packet_received_time", - "unique": false, - "columnNames": [ - "received_time" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" - }, - { - "name": "index_packet_filtered", - "unique": false, - "columnNames": [ - "filtered" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" - }, - { - "name": "index_packet_read", - "unique": false, - "columnNames": [ - "read" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" - } - ] - }, - { - "tableName": "contact_settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", - "fields": [ - { - "fieldPath": "contact_key", - "columnName": "contact_key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "muteUntil", - "columnName": "muteUntil", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastReadMessageUuid", - "columnName": "last_read_message_uuid", - "affinity": "INTEGER" - }, - { - "fieldPath": "lastReadMessageTimestamp", - "columnName": "last_read_message_timestamp", - "affinity": "INTEGER" - }, - { - "fieldPath": "filteringDisabled", - "columnName": "filtering_disabled", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "contact_key" - ] - } - }, - { - "tableName": "log", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "message_type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "received_date", - "columnName": "received_date", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "raw_message", - "columnName": "message", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "fromNum", - "columnName": "from_num", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "portNum", - "columnName": "port_num", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "fromRadio", - "columnName": "from_radio", - "affinity": "BLOB", - "notNull": true, - "defaultValue": "x''" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - }, - "indices": [ - { - "name": "index_log_from_num", - "unique": false, - "columnNames": [ - "from_num" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" - }, - { - "name": "index_log_port_num", - "unique": false, - "columnNames": [ - "port_num" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" - } - ] - }, - { - "tableName": "quick_chat", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "message", - "columnName": "message", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mode", - "columnName": "mode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "uuid" - ] - } - }, - { - "tableName": "reactions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", - "fields": [ - { - "fieldPath": "myNodeNum", - "columnName": "myNodeNum", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "replyId", - "columnName": "reply_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "user_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "emoji", - "columnName": "emoji", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "snr", - "columnName": "snr", - "affinity": "REAL", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "rssi", - "columnName": "rssi", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "hopsAway", - "columnName": "hopsAway", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - }, - { - "fieldPath": "packetId", - "columnName": "packet_id", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "routingError", - "columnName": "routing_error", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "relays", - "columnName": "relays", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "relayNode", - "columnName": "relay_node", - "affinity": "INTEGER" - }, - { - "fieldPath": "to", - "columnName": "to", - "affinity": "TEXT" - }, - { - "fieldPath": "channel", - "columnName": "channel", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "sfpp_hash", - "columnName": "sfpp_hash", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "myNodeNum", - "reply_id", - "user_id", - "emoji" - ] - }, - "indices": [ - { - "name": "index_reactions_reply_id", - "unique": false, - "columnNames": [ - "reply_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" - }, - { - "name": "index_reactions_packet_id", - "unique": false, - "columnNames": [ - "packet_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" - } - ] - }, - { - "tableName": "metadata", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", - "fields": [ - { - "fieldPath": "num", - "columnName": "num", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "proto", - "columnName": "proto", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "num" - ] - }, - "indices": [ - { - "name": "index_metadata_num", - "unique": false, - "columnNames": [ - "num" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" - } - ] - }, - { - "tableName": "device_hardware", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", - "fields": [ - { - "fieldPath": "activelySupported", - "columnName": "actively_supported", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "architecture", - "columnName": "architecture", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "displayName", - "columnName": "display_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "hasInkHud", - "columnName": "has_ink_hud", - "affinity": "INTEGER" - }, - { - "fieldPath": "hasMui", - "columnName": "has_mui", - "affinity": "INTEGER" - }, - { - "fieldPath": "hwModel", - "columnName": "hwModel", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hwModelSlug", - "columnName": "hw_model_slug", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "images", - "columnName": "images", - "affinity": "TEXT" - }, - { - "fieldPath": "lastUpdated", - "columnName": "last_updated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "partitionScheme", - "columnName": "partition_scheme", - "affinity": "TEXT" - }, - { - "fieldPath": "platformioTarget", - "columnName": "platformio_target", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "requiresDfu", - "columnName": "requires_dfu", - "affinity": "INTEGER" - }, - { - "fieldPath": "supportLevel", - "columnName": "support_level", - "affinity": "INTEGER" - }, - { - "fieldPath": "tags", - "columnName": "tags", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "platformio_target" - ] - } - }, - { - "tableName": "firmware_release", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pageUrl", - "columnName": "page_url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "releaseNotes", - "columnName": "release_notes", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "zipUrl", - "columnName": "zip_url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "last_updated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "releaseType", - "columnName": "release_type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "traceroute_node_position", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "logUuid", - "columnName": "log_uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "requestId", - "columnName": "request_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nodeNum", - "columnName": "node_num", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "log_uuid", - "node_num" - ] - }, - "indices": [ - { - "name": "index_traceroute_node_position_log_uuid", - "unique": false, - "columnNames": [ - "log_uuid" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" - }, - { - "name": "index_traceroute_node_position_request_id", - "unique": false, - "columnNames": [ - "request_id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" - } - ], - "foreignKeys": [ - { - "table": "log", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "log_uuid" - ], - "referencedColumns": [ - "uuid" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffca7655fa7c1d69fdd404b1b39d140c')" - ] - } -} \ No newline at end of file diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 451a62174..8062afa76 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -20,7 +20,7 @@ import androidx.room3.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import okio.ByteString.Companion.toByteString import org.junit.After import org.junit.Before @@ -59,7 +59,7 @@ class MigrationTest { ) @Before - fun createDb(): Unit = runTest { + fun createDb(): Unit = runBlocking { val context = ApplicationProvider.getApplicationContext() database = Room.inMemoryDatabaseBuilder( @@ -77,7 +77,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_duplicatePSK() = runTest { + fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking { // PSK \"AQ==\" is base64 for single byte 0x01 val pskBytes = byteArrayOf(0x01).toByteString() @@ -103,7 +103,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_reorder() = runTest { + fun testMigrateChannelsByPSK_reorder() = runBlocking { val pskA = byteArrayOf(0x01).toByteString() val pskB = byteArrayOf(0x02).toByteString() @@ -122,7 +122,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_disambiguateByName() = runTest { + fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A1") @@ -141,7 +141,7 @@ class MigrationTest { } @Test - fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest { + fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking { val pskA = byteArrayOf(0x01).toByteString() insertPacket(channel = 0, text = "Msg A") diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt index b2c89ad73..c917ee066 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.database import okio.ByteString.Companion.encodeUtf8 -import org.meshtastic.core.common.util.normalizeAddress object DatabaseConstants { const val DB_PREFIX: String = "meshtastic_database" @@ -41,6 +40,17 @@ object DatabaseConstants { const val ADDRESS_ANON_EDGE_LEN: Int = 2 } +fun normalizeAddress(addr: String?): String { + val u = addr?.trim()?.uppercase() + val normalized = + when { + u.isNullOrBlank() -> "DEFAULT" + u == "N" || u == "NULL" -> "DEFAULT" + else -> u.replace(":", "") + } + return normalized +} + fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN) fun buildDbName(address: String?): String = if (address.isNullOrBlank()) { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt index 108345265..ba5887f95 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -241,7 +241,6 @@ open class DatabaseManager( victims.forEach { name -> runCatching { - // runCatching intentional: best-effort cleanup must not abort on cancellation closeCachedDatabase(name) deleteDatabase(name) datastore.edit { it.remove(lastUsedKey(name)) } @@ -267,7 +266,6 @@ open class DatabaseManager( if (fs.exists(legacyPath)) { runCatching { - // runCatching intentional: best-effort cleanup must not abort on cancellation closeCachedDatabase(legacy) deleteDatabase(legacy) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 13451e5fc..7bf9014ce 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -94,9 +94,8 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class), AutoMigration(from = 35, to = 36), AutoMigration(from = 36, to = 37), - AutoMigration(from = 37, to = 38), ], - version = 38, + version = 37, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt index c1e399c97..fcdc079f2 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt @@ -17,15 +17,18 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy import androidx.room3.Query -import androidx.room3.Upsert import org.meshtastic.core.database.entity.DeviceHardwareEntity @Dao interface DeviceHardwareDao { - @Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(deviceHardware: DeviceHardwareEntity) - @Upsert suspend fun insertAll(deviceHardware: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(deviceHardware: List) @Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel") suspend fun getByHwModel(hwModel: Int): List diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt index 040941a49..0a5520a07 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt @@ -17,14 +17,16 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy import androidx.room3.Query -import androidx.room3.Upsert import org.meshtastic.core.database.entity.FirmwareReleaseEntity import org.meshtastic.core.database.entity.FirmwareReleaseType @Dao interface FirmwareReleaseDao { - @Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity) @Query("DELETE FROM firmware_release") suspend fun deleteAll() diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt index 35d29c161..967a97ec5 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt @@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog @Dao interface MeshLogDao { - @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem") + @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem") fun getAllLogs(maxItem: Int): Flow> - @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem") + @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem") fun getAllLogsInReceiveOrder(maxItem: Int): Flow> /** @@ -40,7 +40,7 @@ interface MeshLogDao { """ SELECT * FROM log WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum) - ORDER BY received_date DESC LIMIT :maxItem + ORDER BY received_date DESC LIMIT 0,:maxItem """, ) fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow> diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index 407a4d853..e11d10f50 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -17,7 +17,9 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao +import androidx.room3.Insert import androidx.room3.MapColumn +import androidx.room3.OnConflictStrategy import androidx.room3.Query import androidx.room3.Transaction import androidx.room3.Upsert @@ -35,9 +37,6 @@ interface NodeInfoDao { companion object { const val KEY_SIZE = 32 - - /** SQLite has a limit of ~999 bind parameters per query. */ - const val MAX_BIND_PARAMS = 999 } /** @@ -169,7 +168,8 @@ interface NodeInfoDao { @Query("SELECT * FROM my_node") fun getMyNodeInfo(): Flow - @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setMyNodeInfo(myInfo: MyNodeEntity) @Query("DELETE FROM my_node") suspend fun clearMyNodeInfo() @@ -284,15 +284,9 @@ interface NodeInfoDao { @Transaction suspend fun getNodeByNum(num: Int): NodeWithRelations? - @Query("SELECT * FROM nodes WHERE num IN (:nodeNums)") - suspend fun getNodeEntitiesByNums(nodeNums: List): List - @Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1") suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity? - @Query("SELECT * FROM nodes WHERE public_key IN (:publicKeys)") - suspend fun findNodesByPublicKeys(publicKeys: List): List - @Upsert suspend fun doUpsert(node: NodeEntity) @Transaction @@ -301,82 +295,17 @@ interface NodeInfoDao { doUpsert(verifiedNode) } - @Upsert suspend fun putAll(nodes: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putAll(nodes: List) @Query("UPDATE nodes SET notes = :notes WHERE num = :num") suspend fun setNodeNotes(num: Int, notes: String) - /** - * Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two - * queries instead of N individual queries, then processes each node in memory. - */ - @Suppress("NestedBlockDepth") - private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List { - // Prepare all incoming nodes (populate denormalized fields) - incomingNodes.forEach { node -> - node.publicKey = node.user.public_key - if (node.user.hw_model != HardwareModel.UNSET) { - node.longName = node.user.long_name - node.shortName = node.user.short_name - } else { - node.longName = null - node.shortName = null - } - } - - // Batch fetch all existing nodes by num (chunked for SQLite bind-param limit) - val existingNodesMap = - incomingNodes - .map { it.num } - .chunked(MAX_BIND_PARAMS) - .flatMap { getNodeEntitiesByNums(it) } - .associateBy { it.num } - - // Partition into updates vs. inserts and resolve existing nodes in-memory - val result = mutableListOf() - val newNodes = mutableListOf() - for (incoming in incomingNodes) { - val existing = existingNodesMap[incoming.num] - if (existing != null) { - result.add(handleExistingNodeUpsertValidation(existing, incoming)) - } else { - newNodes.add(incoming) - } - } - - // Batch validate new nodes' public keys (one query instead of N) - val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct() - val pkConflicts = - if (publicKeysToCheck.isNotEmpty()) { - publicKeysToCheck - .chunked(MAX_BIND_PARAMS) - .flatMap { findNodesByPublicKeys(it) } - .associateBy { it.publicKey } - } else { - emptyMap() - } - - for (newNode in newNodes) { - if ((newNode.publicKey?.size ?: 0) > 0) { - val conflicting = pkConflicts[newNode.publicKey] - if (conflicting != null && conflicting.num != newNode.num) { - result.add(conflicting) - } else { - result.add(newNode) - } - } else { - result.add(newNode) - } - } - - return result - } - @Transaction suspend fun installConfig(mi: MyNodeEntity, nodes: List) { clearMyNodeInfo() setMyNodeInfo(mi) - putAll(getVerifiedNodesForUpsert(nodes)) + putAll(nodes.map { getVerifiedNodeForUpsert(it) }) } /** diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index c2ef9c516..1419d51e7 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -18,9 +18,7 @@ package org.meshtastic.core.database.dao import androidx.paging.PagingSource import androidx.room3.Dao -import androidx.room3.Insert import androidx.room3.MapColumn -import androidx.room3.OnConflictStrategy import androidx.room3.Query import androidx.room3.Transaction import androidx.room3.Update @@ -309,16 +307,6 @@ interface PacketDao { ) suspend fun getPacketByPacketId(packetId: Int): PacketEntity? - @Transaction - @Query( - """ - SELECT * FROM packet - WHERE packet_id IN (:packetIds) - AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) - """, - ) - suspend fun getPacketsByPacketIds(packetIds: List): List - @Query( """ SELECT * FROM packet @@ -338,15 +326,8 @@ interface PacketDao { ) suspend fun findPacketBySfppHash(hash: ByteString): Packet? - @Query( - """ - SELECT data FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) - AND json_extract(data, '${"$"}.status') = 'QUEUED' - ORDER BY received_time ASC - """, - ) - suspend fun getQueuedPackets(): List + @Transaction + suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED } @Query( """ @@ -378,24 +359,23 @@ interface PacketDao { @Upsert suspend fun upsertContactSettings(contacts: List) - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertContactSettingsIgnore(contacts: List) - - @Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)") - suspend fun updateMuteUntil(contactKeys: List, muteUntil: Long) - @Transaction suspend fun setMuteUntil(contacts: List, until: Long) { - val absoluteMuteUntil = - when { - until == Long.MAX_VALUE -> Long.MAX_VALUE - until == 0L -> 0L - else -> nowMillis + until - } - // Ensure rows exist for all contacts (IGNORE avoids overwriting existing data) - insertContactSettingsIgnore(contacts.map { ContactSettings(contact_key = it) }) - // Atomic column-level update — no read-then-write race - updateMuteUntil(contacts, absoluteMuteUntil) + val contactList = contacts.map { contact -> + // Always mute + val absoluteMuteUntil = + if (until == Long.MAX_VALUE) { + Long.MAX_VALUE + } else if (until == 0L) { // unmute + 0L + } else { + nowMillis + until + } + + getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil) + ?: ContactSettings(contact_key = contact, muteUntil = absoluteMuteUntil) + } + upsertContactSettings(contactList) } @Upsert suspend fun insert(reaction: ReactionEntity) @@ -499,10 +479,9 @@ interface PacketDao { val indexMap = oldSettings .mapIndexed { oldIndex, oldChannel -> - val pskMatches = - newSettings.mapIndexedNotNull { index, channel -> - if (channel.psk == oldChannel.psk) index to channel else null - } + val pskMatches = newSettings.mapIndexedNotNull { index, channel -> + if (channel.psk == oldChannel.psk) index to channel else null + } val newIndex = when { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt index fde388ce5..2e7f6c549 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt @@ -17,8 +17,9 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy import androidx.room3.Query -import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import org.meshtastic.core.database.entity.TracerouteNodePositionEntity @@ -31,5 +32,6 @@ interface TracerouteNodePositionDao { @Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid") suspend fun deleteByLogUuid(logUuid: String) - @Upsert suspend fun insertAll(entities: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index fed88eef9..13d10193c 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -118,7 +118,6 @@ data class MetadataEntity( Index(value = ["hops_away"]), Index(value = ["is_favorite"]), Index(value = ["last_heard", "is_favorite"]), - Index(value = ["public_key"]), ], ) data class NodeEntity( diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt index d01171751..16b1e66e4 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -74,9 +74,6 @@ data class PacketEntity( Index(value = ["contact_key"]), Index(value = ["contact_key", "port_num", "received_time"]), Index(value = ["packet_id"]), - Index(value = ["received_time"]), - Index(value = ["filtered"]), - Index(value = ["read"]), ], ) data class Packet( @@ -101,12 +98,9 @@ data class Packet( fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK - val candidateRelayNodes = - nodes.filter { - it.num != ourNodeNum && - it.lastHeard != 0 && - (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix - } + val candidateRelayNodes = nodes.filter { + it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix + } val closestRelayNode = if (candidateRelayNodes.size == 1) { diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt index 71a7fef1c..6da9df5b7 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt @@ -271,42 +271,6 @@ abstract class CommonPacketDaoTest { assertFalse(excludingFiltered.any { it.packet.filtered }) } - @Test - fun testGetPacketsByPacketIdsChunked() = runTest { - // Regression test for SQLITE_MAX_VARIABLE_NUMBER (999) limit. Inserting >999 packets and - // looking them up by id must not throw; callers are expected to chunk, and each chunk - // must return the correct rows. - val totalPackets = 2000 - val chunkSize = NodeInfoDao.MAX_BIND_PARAMS - val contactKey = "chunk-test" - val baseTime = nowMillis - val packetIds = (1..totalPackets).toList() - - packetIds.forEach { id -> - packetDao.insert( - Packet( - uuid = 0L, - myNodeNum = myNodeNum, - port_num = PortNum.TEXT_MESSAGE_APP.value, - contact_key = contactKey, - received_time = baseTime + id, - read = false, - data = - DataPacket( - to = DataPacket.ID_BROADCAST, - bytes = "Chunk $id".encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ), - packetId = id, - ), - ) - } - - val fetched = packetIds.chunked(chunkSize).flatMap { packetDao.getPacketsByPacketIds(it) } - assertEquals(totalPackets, fetched.size) - assertEquals(packetIds.toSet(), fetched.map { it.packet.packetId }.toSet()) - } - companion object { private const val SAMPLE_SIZE = 10 } diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 7d46cc831..903dde119 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -24,11 +24,7 @@ plugins { kotlin { jvm() - android { - namespace = "org.meshtastic.core.datastore" - androidResources.enable = false - withHostTest {} - } + android { namespace = "org.meshtastic.core.datastore" } sourceSets { commonMain.dependencies { @@ -40,11 +36,5 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kermit) } - - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.okio) - } } } diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt index 9de792a84..94ef1c605 100644 --- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt +++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt @@ -50,7 +50,7 @@ class PreferencesDataStoreModule { @Named("CorePreferencesDataStore") fun providePreferencesDataStore( context: Context, - @Named(DATASTORE_SCOPE) scope: CoroutineScope, + @Named("DataStoreScope") scope: CoroutineScope, ): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), migrations = @@ -66,7 +66,7 @@ class LocalConfigDataStoreModule { @Named("CoreLocalConfigDataStore") fun provideLocalConfigDataStore( context: Context, - @Named(DATASTORE_SCOPE) scope: CoroutineScope, + @Named("DataStoreScope") scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule { @Named("CoreModuleConfigDataStore") fun provideModuleConfigDataStore( context: Context, - @Named(DATASTORE_SCOPE) scope: CoroutineScope, + @Named("DataStoreScope") scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -104,7 +104,7 @@ class ChannelSetDataStoreModule { @Named("CoreChannelSetDataStore") fun provideChannelSetDataStore( context: Context, - @Named(DATASTORE_SCOPE) scope: CoroutineScope, + @Named("DataStoreScope") scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( @@ -123,7 +123,7 @@ class LocalStatsDataStoreModule { @Named("CoreLocalStatsDataStore") fun provideLocalStatsDataStore( context: Context, - @Named(DATASTORE_SCOPE) scope: CoroutineScope, + @Named("DataStoreScope") scope: CoroutineScope, ): DataStore = DataStoreFactory.create( storage = OkioStorage( diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt index 3cb3cabe8..aa81f1ac6 100644 --- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt +++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt @@ -24,17 +24,10 @@ import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.ioDispatcher -/** - * Koin qualifier for the application-scoped [CoroutineScope] shared by all [DataStore] instances. - * - * Used with `@Named(DATASTORE_SCOPE)` in Koin annotations and `named(DATASTORE_SCOPE)` in manual DSL modules. - */ -const val DATASTORE_SCOPE = "DataStoreScope" - @Module @ComponentScan("org.meshtastic.core.datastore") class CoreDatastoreModule { @Single - @Named(DATASTORE_SCOPE) + @Named("DataStoreScope") fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) } diff --git a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt deleted file mode 100644 index 3acd29cb9..000000000 --- a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt +++ /dev/null @@ -1,286 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.datastore - -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive -import okio.FileSystem -import okio.Path -import org.meshtastic.core.datastore.model.RecentAddress -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -@OptIn(ExperimentalUuidApi::class) -class RecentAddressesDataSourceTest { - private lateinit var tmpDir: Path - private lateinit var dataSource: RecentAddressesDataSource - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - - @BeforeTest - fun setup() { - tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "recentAddressesTest-${Uuid.random()}" - FileSystem.SYSTEM.createDirectories(tmpDir) - val dataStore = - PreferenceDataStoreFactory.createWithPath( - scope = testScope, - produceFile = { tmpDir / "test.preferences_pb" }, - ) - dataSource = RecentAddressesDataSource(dataStore) - } - - @AfterTest - fun tearDown() { - FileSystem.SYSTEM.deleteRecursively(tmpDir) - } - - // ---- recentAddresses flow ---- - - @Test - fun `recentAddresses emits empty list when no data stored`() = testScope.runTest { - val result = dataSource.recentAddresses.first() - assertTrue(result.isEmpty()) - } - - @Test - fun `setRecentAddresses persists and emits the list`() = testScope.runTest { - val addresses = - listOf( - RecentAddress(address = "192.168.1.1", name = "Home"), - RecentAddress(address = "10.0.0.1", name = "Office"), - ) - dataSource.setRecentAddresses(addresses) - - val result = dataSource.recentAddresses.first() - assertEquals(addresses, result) - } - - @Test - fun `setRecentAddresses overwrites previous value`() = testScope.runTest { - dataSource.setRecentAddresses(listOf(RecentAddress("1.2.3.4", "Old"))) - dataSource.setRecentAddresses(listOf(RecentAddress("5.6.7.8", "New"))) - - val result = dataSource.recentAddresses.first() - assertEquals(1, result.size) - assertEquals("5.6.7.8", result[0].address) - } - - // ---- add() LRU behaviour ---- - - @Test - fun `add to empty list stores single entry`() = testScope.runTest { - dataSource.add(RecentAddress("192.168.0.1", "Router")) - - val result = dataSource.recentAddresses.first() - assertEquals(1, result.size) - assertEquals("192.168.0.1", result[0].address) - } - - @Test - fun `add prepends new address to front`() = testScope.runTest { - dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "Existing"))) - dataSource.add(RecentAddress("2.2.2.2", "New")) - - val result = dataSource.recentAddresses.first() - assertEquals("2.2.2.2", result[0].address) - assertEquals("1.1.1.1", result[1].address) - } - - @Test - fun `add deduplicates by address moving existing entry to front with updated name`() = testScope.runTest { - dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "First"), RecentAddress("2.2.2.2", "Second"))) - dataSource.add(RecentAddress("2.2.2.2", "Second-updated")) - - val result = dataSource.recentAddresses.first() - assertEquals(2, result.size) - assertEquals("2.2.2.2", result[0].address) - assertEquals("Second-updated", result[0].name) - assertEquals("1.1.1.1", result[1].address) - } - - @Test - fun `add enforces CACHE_CAPACITY of 3 evicting oldest entry`() = testScope.runTest { - dataSource.setRecentAddresses( - listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")), - ) - dataSource.add(RecentAddress("4.4.4.4", "D")) - - val result = dataSource.recentAddresses.first() - assertEquals(3, result.size) - assertEquals("4.4.4.4", result[0].address) - assertEquals("1.1.1.1", result[1].address) - assertEquals("2.2.2.2", result[2].address) - assertFalse(result.any { it.address == "3.3.3.3" }) - } - - @Test - fun `add re-adding the same address at front keeps capacity`() = testScope.runTest { - dataSource.setRecentAddresses( - listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")), - ) - dataSource.add(RecentAddress("1.1.1.1", "A")) - - val result = dataSource.recentAddresses.first() - assertEquals(3, result.size) - assertEquals("1.1.1.1", result[0].address) - } - - // ---- remove() ---- - - @Test - fun `remove deletes the matching address`() = testScope.runTest { - dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"))) - dataSource.remove("1.1.1.1") - - val result = dataSource.recentAddresses.first() - assertEquals(1, result.size) - assertEquals("2.2.2.2", result[0].address) - } - - @Test - fun `remove on unknown address is a no-op`() = testScope.runTest { - dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"))) - dataSource.remove("9.9.9.9") - - val result = dataSource.recentAddresses.first() - assertEquals(1, result.size) - } - - @Test - fun `remove last address yields empty list`() = testScope.runTest { - dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"))) - dataSource.remove("1.1.1.1") - - assertTrue(dataSource.recentAddresses.first().isEmpty()) - } - - // ---- legacy JSON parsing (via LegacyParsingHarness) ---- - - @Test - fun `legacy JsonObject array is parsed correctly`() = testScope.runTest { - val legacyJson = - """[{"address":"192.168.1.100","name":"NodeA"},{"address":"192.168.1.101","name":"NodeB"}]""" - val result = LegacyParsingHarness(legacyJson).recentAddresses.first() - - assertEquals(2, result.size) - assertEquals("192.168.1.100", result[0].address) - assertEquals("NodeA", result[0].name) - assertEquals("192.168.1.101", result[1].address) - assertEquals("NodeB", result[1].name) - } - - @Test - fun `legacy bare string JsonPrimitive array is parsed correctly`() = testScope.runTest { - // Old clients stored plain IP strings with no name field - val legacyJson = """["192.168.1.50","10.0.0.2"]""" - val result = LegacyParsingHarness(legacyJson).recentAddresses.first() - - assertEquals(2, result.size) - assertEquals("192.168.1.50", result[0].address) - assertEquals("Meshtastic", result[0].name) - assertEquals("10.0.0.2", result[1].address) - assertEquals("Meshtastic", result[1].name) - } - - @Test - fun `legacy JsonObject missing address field is skipped`() = testScope.runTest { - val legacyJson = """[{"name":"NoAddress"},{"address":"1.2.3.4","name":"Good"}]""" - val result = LegacyParsingHarness(legacyJson).recentAddresses.first() - - assertEquals(1, result.size) - assertEquals("1.2.3.4", result[0].address) - } - - @Test - fun `legacy JsonObject missing name field is skipped`() = testScope.runTest { - val legacyJson = """[{"address":"1.2.3.4"},{"address":"5.6.7.8","name":"Good"}]""" - val result = LegacyParsingHarness(legacyJson).recentAddresses.first() - - assertEquals(1, result.size) - assertEquals("5.6.7.8", result[0].address) - } - - @Test - fun `legacy nested JsonArray entries are skipped`() = testScope.runTest { - val legacyJson = """[["nested","array"],{"address":"1.2.3.4","name":"Good"}]""" - val result = LegacyParsingHarness(legacyJson).recentAddresses.first() - - assertEquals(1, result.size) - assertEquals("1.2.3.4", result[0].address) - } - - @Test - fun `legacy mixed array handles all element types`() = testScope.runTest { - // JsonPrimitive + valid JsonObject + malformed JsonObject + nested JsonArray - val legacyJson = """["10.0.0.1",{"address":"10.0.0.2","name":"Node"},{"name":"bad"},[1,2]]""" - val result = LegacyParsingHarness(legacyJson).recentAddresses.first() - - assertEquals(2, result.size) - assertEquals("10.0.0.1", result[0].address) - assertEquals("Meshtastic", result[0].name) - assertEquals("10.0.0.2", result[1].address) - } -} - -/** - * Test harness that mirrors the private legacy parsing logic of [RecentAddressesDataSource] without needing to bypass - * encapsulation. Exposes a [Flow] that emits the result of parsing a raw legacy JSON string using the same rules as the - * production fallback path. - */ -private class LegacyParsingHarness(private val rawJson: String) { - val recentAddresses: Flow> = flow { - val jsonArray = Json.parseToJsonElement(rawJson).jsonArray - emit( - jsonArray.mapNotNull { item -> - when (item) { - is JsonObject -> { - val address = item["address"]?.jsonPrimitive?.contentOrNull - val name = item["name"]?.jsonPrimitive?.contentOrNull - if (address != null && name != null) { - RecentAddress(address = address, name = name) - } else { - null - } - } - is JsonPrimitive -> { - item.contentOrNull?.let { RecentAddress(address = it, name = "Meshtastic") } - } - is JsonArray -> null - } - }, - ) - } -} diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 92374706a..4e01fc223 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -58,8 +58,6 @@ kotlin { implementation(libs.androidx.test.runner) } } - - commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/model/consumer-rules.pro b/core/model/consumer-rules.pro new file mode 100644 index 000000000..5f75d687d --- /dev/null +++ b/core/model/consumer-rules.pro @@ -0,0 +1,2 @@ +-keep class org.meshtastic.core.model.DataPacket +-keep class org.meshtastic.core.model.DataPacket$CREATOR diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt new file mode 100644 index 000000000..473e482e2 --- /dev/null +++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model.util + +import org.meshtastic.core.common.util.nowInstant +import org.meshtastic.core.common.util.toDate +import org.meshtastic.core.common.util.toInstant +import java.text.DateFormat +import kotlin.time.Duration.Companion.hours + +private val DAY_DURATION = 24.hours + +/** + * Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string + * representing the date. + * + * @param time The time in milliseconds + * @return Formatted date or time string, or null if time is 0 + */ +fun getShortDate(time: Long): String? { + if (time == 0L) return null + val instant = time.toInstant() + val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION + + return if (isWithin24Hours) { + DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate()) + } else { + DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate()) + } +} + +/** + * Calculates the remaining mute time in days and hours. + * + * @param remainingMillis The remaining time in milliseconds + * @return Pair of (days, hours), where days is Int and hours is Double + */ diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt index 99debb5ab..13b0789de 100644 --- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt +++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt @@ -17,13 +17,12 @@ package org.meshtastic.core.model.util import android.net.Uri -import com.eygraber.uri.toKmpUri import org.meshtastic.core.common.util.CommonUri import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.SharedContact /** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */ -fun Uri.toCommonUri(): CommonUri = this.toKmpUri() +fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString()) /** Bridge extension for Android clients. */ fun Uri.dispatchMeshtasticUri( diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 4e02ae2a7..65096604f 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -34,7 +34,7 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Ability to mute notifications from specific nodes via admin messages. */ val canMuteNode = atLeast(V2_7_18) - /** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */ + /** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */ val canRequestNeighborInfo = atLeast(UNRELEASED) /** Ability to send verified shared contacts. Supported since firmware v2.7.12. */ @@ -49,8 +49,8 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */ val supportsQrCodeSharing = atLeast(V2_6_8) - /** Support for Status Message module. Supported since firmware v2.8.0. */ - val supportsStatusMessage = atLeast(V2_8_0) + /** Support for Status Message module. Supported since firmware v2.7.17. */ + val supportsStatusMessage = atLeast(V2_7_17) /** Support for Traffic Management module. Supported since firmware v3.0.0. */ val supportsTrafficManagementConfig = atLeast(V3_0_0) @@ -69,9 +69,9 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl private val V2_6_9 = DeviceVersion("2.6.9") private val V2_6_10 = DeviceVersion("2.6.10") private val V2_7_12 = DeviceVersion("2.7.12") + private val V2_7_17 = DeviceVersion("2.7.17") private val V2_7_18 = DeviceVersion("2.7.18") private val V2_7_19 = DeviceVersion("2.7.19") - private val V2_8_0 = DeviceVersion("2.8.0") private val V3_0_0 = DeviceVersion("3.0.0") private val UNRELEASED = DeviceVersion("9.9.9") } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt index c8bbdadb5..0af5a0efd 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt @@ -16,16 +16,24 @@ */ package org.meshtastic.core.model -sealed interface ConnectionState { +sealed class ConnectionState { /** We are disconnected from the device, and we should be trying to reconnect. */ - data object Disconnected : ConnectionState + data object Disconnected : ConnectionState() /** We are currently attempting to connect to the device. */ - data object Connecting : ConnectionState + data object Connecting : ConnectionState() /** We are connected to the device and communicating normally. */ - data object Connected : ConnectionState + data object Connected : ConnectionState() /** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */ - data object DeviceSleep : ConnectionState + data object DeviceSleep : ConnectionState() + + fun isConnected() = this == Connected + + fun isConnecting() = this == Connecting + + fun isDisconnected() = this == Disconnected + + fun isDeviceSleep() = this == DeviceSleep } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt deleted file mode 100644 index 4d3bfca10..000000000 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model - -/** - * App-level MQTT proxy connection state, decoupled from the MQTT library's internal type. - * - * Modeled as a sealed class so disconnect / reconnect events can carry diagnostic context — the user-facing reason for - * an unexpected disconnect, or the most recent reconnect attempt failure — without requiring downstream consumers to - * depend on the MQTT library's exception types. - */ -sealed class MqttConnectionState { - /** The MQTT proxy has not been started (disabled or not yet initialized). */ - data object Inactive : MqttConnectionState() - - /** The MQTT client is actively connecting to the broker. */ - data object Connecting : MqttConnectionState() - - /** The MQTT client is connected and subscribed to topics. */ - data object Connected : MqttConnectionState() - - /** - * The MQTT client lost connection and is attempting to reconnect. - * - * @property attempt 1-based attempt counter for the current reconnect loop. - * @property lastError Localized message from the most recent reconnect failure, if any. - */ - data class Reconnecting(val attempt: Int = 0, val lastError: String? = null) : MqttConnectionState() - - /** - * The MQTT client is not connected to the broker. - * - * @property reason Localized failure message for an unexpected disconnect, or `null` for the idle / initial / - * intentional-close case (use [Idle]). - */ - data class Disconnected(val reason: String? = null) : MqttConnectionState() { - companion object { - /** Singleton for the idle / no-reason disconnected state. */ - val Idle: Disconnected = Disconnected(reason = null) - } - } -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt deleted file mode 100644 index e3cb7c77a..000000000 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model - -/** - * UI-friendly outcome of a one-shot MQTT broker reachability probe. - * - * Mirrors the failure shapes of `org.meshtastic.mqtt.ProbeResult` but stays in the model module so feature/UI code can - * consume the result without depending on the MQTT library. - */ -sealed class MqttProbeStatus { - /** Probe is currently in flight. */ - data object Probing : MqttProbeStatus() - - /** - * Broker accepted the connection. [serverInfo] is a short human-readable summary of any CONNACK properties that are - * useful to surface to the user. - */ - data class Success(val serverInfo: String?) : MqttProbeStatus() - - /** Broker rejected the connection (CONNACK with non-zero reason code). */ - data class Rejected(val reasonCode: Int, val reason: String?, val serverReference: String?) : MqttProbeStatus() - - /** DNS lookup failed. */ - data class DnsFailure(val message: String?) : MqttProbeStatus() - - /** TCP socket could not be opened. */ - data class TcpFailure(val message: String?) : MqttProbeStatus() - - /** TLS handshake failed. */ - data class TlsFailure(val message: String?) : MqttProbeStatus() - - /** Probe exceeded its timeout. */ - data class Timeout(val timeoutMs: Long) : MqttProbeStatus() - - /** Any other / unclassified failure. */ - data class Other(val message: String?) : MqttProbeStatus() -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 70dea8574..13eccae2a 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -19,9 +19,10 @@ package org.meshtastic.core.model import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.GPSFormat -import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.bearing +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.latLongToMeter +import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.proto.Config @@ -142,26 +143,34 @@ data class Node( private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List { val temp = if ((temperature ?: 0f) != 0f) { - MetricFormatter.temperature(temperature ?: 0f, isFahrenheit) + if (isFahrenheit) { + formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f)) + } else { + formatString("%.1f°C", temperature) + } } else { null } - val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null + val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null val soilTemperatureStr = if ((soil_temperature ?: 0f) != 0f) { - MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit) + if (isFahrenheit) { + formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f)) + } else { + formatString("%.1f°C", soil_temperature) + } } else { null } val soilMoistureRange = 0..100 val soilMoisture = if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) { - MetricFormatter.percent(soil_moisture ?: 0) + formatString("%d%%", soil_moisture) } else { null } - val voltage = if ((this.voltage ?: 0f) != 0f) MetricFormatter.voltage(this.voltage ?: 0f) else null - val current = if ((current ?: 0f) != 0f) MetricFormatter.current(current ?: 0f) else null + val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null + val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null return listOfNotNull( @@ -190,12 +199,9 @@ data class Node( fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK - val candidateRelayNodes = - nodes.filter { - it.num != ourNodeNum && - it.lastHeard != 0 && - (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix - } + val candidateRelayNodes = nodes.filter { + it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix + } val closestRelayNode = if (candidateRelayNodes.size == 1) { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index 84994e628..54797eb75 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -28,16 +28,7 @@ import org.meshtastic.proto.ClientNotification */ @Suppress("TooManyFunctions") interface RadioController { - /** - * Canonical app-level connection state, delegated from [ServiceRepository][connectionState]. - * - * This exposes the same single source of truth as `ServiceRepository.connectionState`, surfaced through the - * controller interface for convenience in feature modules and ViewModels that depend on [RadioController] rather - * than [ServiceRepository] directly. - * - * This is **not** the transport-level state — it reflects the fully reconciled app-level state including handshake - * progress and device sleep policy. - */ + /** Reactive connection state of the radio. */ val connectionState: StateFlow /** diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt index dfe70fd92..6f27bb0e6 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -18,11 +18,8 @@ package org.meshtastic.core.model.util -import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.Telemetry /** @@ -35,7 +32,7 @@ val Any?.anonymize: String get() = this.anonymize() /** A version of anonymize that allows passing in a custom minimum length */ -fun Any?.anonymize(maxLen: Int = 3) = if (this != null) "...${this.toString().takeLast(maxLen)}" else "null" +fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null" // A toString that makes sure all newlines are removed (for nice logging). fun Any.toOneLineString() = this.toString().replace('\n', ' ') @@ -51,24 +48,6 @@ fun MeshPacket.toOneLineString(): String { return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') } -fun Channel.toOneLineString(): String { - // Redact the channel preshared key (psk) from logs. - val redactedFields = """(psk)=[^,}]+""" - return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') -} - -fun ModuleConfig.toOneLineString(): String { - // Redact MQTT credentials from logs. - val redactedFields = """(password|username)=[^,}]+""" - return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') -} - -fun MyNodeInfo.toOneLineString(): String { - // Redact the hardware unique identifier from logs. - val redactedFields = """(device_id)=[^,}]+""" - return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ') -} - fun Any.toPIIString() = if (!isDebug) { "" } else { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt index ebdcc0f5e..ca035a7fd 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt @@ -16,27 +16,7 @@ */ package org.meshtastic.core.model.util -import okio.ByteString.Companion.toByteString - /** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */ -object SfppHasher { - private const val HASH_SIZE = 16 - private const val INT_BYTES = 4 - private const val INT_COUNT = 3 - private const val SHIFT_8 = 8 - private const val SHIFT_16 = 16 - private const val SHIFT_24 = 24 - - fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray { - val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT) - encryptedPayload.copyInto(input) - var offset = encryptedPayload.size - for (value in intArrayOf(to, from, id)) { - input[offset++] = value.toByte() - input[offset++] = (value shr SHIFT_8).toByte() - input[offset++] = (value shr SHIFT_16).toByte() - input[offset++] = (value shr SHIFT_24).toByte() - } - return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE) - } +expect object SfppHasher { + fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt index 4b3f5d149..b2e175382 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt @@ -107,7 +107,7 @@ fun compareUsers(oldUser: User, newUser: User): String { return if (changes.isEmpty()) { "No changes detected." } else { - "Changes:\n${changes.joinToString("\n")}" + "Changes:\n" + changes.joinToString("\n") } } diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt index 365a47c61..ecaf88db6 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt @@ -68,9 +68,9 @@ class CapabilitiesTest { } @Test - fun supportsStatusMessage_requires_V2_8_0() { - assertFalse(caps("2.7.21").supportsStatusMessage) - assertTrue(caps("2.8.0").supportsStatusMessage) + fun supportsStatusMessage_requires_V2_7_17() { + assertFalse(caps("2.7.16").supportsStatusMessage) + assertTrue(caps("2.7.17").supportsStatusMessage) } @Test diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt deleted file mode 100644 index 917414e3d..000000000 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model.util - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals - -class SfppHasherTest { - - @Test - fun outputIsAlways16Bytes() { - val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1) - assertEquals(16, hash.size) - } - - @Test - fun emptyPayloadProduces16Bytes() { - val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0) - assertEquals(16, hash.size) - } - - @Test - fun deterministicOutput() { - val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3) - val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3) - assertEquals(a.toList(), b.toList()) - } - - @Test - fun differentPayloadsProduceDifferentHashes() { - val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3) - val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3) - assertNotEquals(a.toList(), b.toList()) - } - - @Test - fun differentIdsProduceDifferentHashes() { - val payload = byteArrayOf(0x10, 0x20) - val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100) - val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101) - assertNotEquals(a.toList(), b.toList()) - } - - @Test - fun differentFromProduceDifferentHashes() { - val payload = byteArrayOf(0x10, 0x20) - val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3) - val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3) - assertNotEquals(a.toList(), b.toList()) - } - - @Test - fun maxIntValues() { - val hash = - SfppHasher.computeMessageHash( - byteArrayOf(0xFF.toByte()), - to = Int.MAX_VALUE, - from = Int.MAX_VALUE, - id = Int.MAX_VALUE, - ) - assertEquals(16, hash.size) - } - - @Test - fun littleEndianByteOrder() { - // Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian) - val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0) - val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0) - // Different byte orderings must produce different hashes - assertNotEquals(hashA.toList(), hashB.toList()) - } -} diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt index d17abd4a3..7545a00a7 100644 --- a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt +++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt @@ -20,3 +20,7 @@ package org.meshtastic.core.model.util actual fun getShortDateTime(time: Long): String = "" actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size) + +actual object SfppHasher { + actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32) +} diff --git a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt new file mode 100644 index 000000000..b1c25110b --- /dev/null +++ b/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model.util + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest + +actual object SfppHasher { + private const val HASH_SIZE = 16 + private const val INT_BYTES = 4 + + actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(encryptedPayload) + digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array()) + digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array()) + digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array()) + return digest.digest().copyOf(HASH_SIZE) + } +} diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 858229b69..99a0802ae 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -32,7 +32,5 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) implementation(libs.kermit) } - - commonTest.dependencies { implementation(projects.core.testing) } } } diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt index c36375356..c4d3ac044 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt @@ -111,35 +111,4 @@ class MultiBackstackTest { assertEquals(2, multiBackstack.activeBackStack.size) assertEquals(SettingsRoute.About, multiBackstack.activeBackStack.last()) } - - @Test - fun `handleDeepLink from different tab switches tab and sets stack`() { - // Start on Connections tab - val startTab = TopLevelDestination.Connections.route - val multiBackstack = MultiBackstack(startTab) - - val connectionsStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Connections.route)) } - val nodesStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route)) } - - multiBackstack.backStacks = - mapOf( - TopLevelDestination.Connections.route to connectionsStack, - TopLevelDestination.Nodes.route to nodesStack, - ) - - // Verify we start on Connections - assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute) - - // Deep-link to a TracerouteMap on the Nodes tab (this is the exact pattern - // MeshtasticAppShell uses for traceroute alert "View on Map") - val tracerouteMap = NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 42, logUuid = "abc") - multiBackstack.handleDeepLink(listOf(NodesRoute.NodesGraph, tracerouteMap)) - - // Should have switched to the Nodes tab - assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute) - // Stack should contain the graph root + the traceroute map route - assertEquals(2, multiBackstack.activeBackStack.size) - assertEquals(NodesRoute.NodesGraph, multiBackstack.activeBackStack.first()) - assertEquals(tracerouteMap, multiBackstack.activeBackStack.last()) - } } diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index f2fb85d7f..1c0d14a01 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -40,11 +40,11 @@ kotlin { implementation(projects.core.ble) implementation(libs.okio) - api(libs.meshtastic.mqtt.client) + implementation(libs.kmqtt.client) + implementation(libs.kmqtt.common) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.logging) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.kermit) implementation(libs.jetbrains.lifecycle.runtime) diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt index 426c6700b..28eb2175d 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.network.radio import android.content.Context -import android.hardware.usb.UsbManager import android.provider.Settings import org.koin.core.annotation.Single import org.meshtastic.core.ble.BleConnectionFactory @@ -26,23 +25,21 @@ import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory /** * Android implementation of [RadioTransportFactory]. Handles pure-KMP transports (BLE) via [BaseRadioTransportFactory] - * while creating platform-specific connections (TCP, USB/Serial, Mock, NOP) directly in [createPlatformTransport]. + * while delegating legacy platform-specific connections (like USB/Serial, TCP, and Mocks) to the Android-specific + * [InterfaceFactory]. */ @Single(binds = [RadioTransportFactory::class]) @Suppress("LongParameterList") class AndroidRadioTransportFactory( private val context: Context, + private val interfaceFactory: Lazy, private val buildConfigProvider: BuildConfigProvider, - private val usbRepository: UsbRepository, - private val usbManager: UsbManager, scanner: BleScanner, bluetoothRepository: BluetoothRepository, connectionFactory: BleConnectionFactory, @@ -51,50 +48,13 @@ class AndroidRadioTransportFactory( override val supportedDeviceTypes: List = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) - override fun isMockTransport(): Boolean = + override fun isMockInterface(): Boolean = buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true" - override fun isPlatformAddressValid(address: String): Boolean { - val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } ?: return false - val rest = address.substring(1) - return when (interfaceId) { - InterfaceId.MOCK, - InterfaceId.NOP, - InterfaceId.TCP, - -> true - InterfaceId.SERIAL -> { - val deviceMap = usbRepository.serialDevices.value - val driver = deviceMap[rest] ?: deviceMap.values.firstOrNull() - driver != null && usbManager.hasPermission(driver.device) - } - InterfaceId.BLUETOOTH -> true // Handled by base class - } - } + override fun isPlatformAddressValid(address: String): Boolean = interfaceFactory.value.addressValid(address) override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport { - val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } - val rest = address.substring(1) - - return when (interfaceId) { - InterfaceId.MOCK -> MockRadioTransport(callback = service, scope = service.serviceScope, address = rest) - InterfaceId.TCP -> - TcpRadioTransport( - callback = service, - scope = service.serviceScope, - dispatchers = dispatchers, - address = rest, - ) - InterfaceId.SERIAL -> - SerialRadioTransport( - callback = service, - scope = service.serviceScope, - usbRepository = usbRepository, - address = rest, - ) - InterfaceId.NOP, - null, - -> NopRadioTransport(rest) - InterfaceId.BLUETOOTH -> error("BLE addresses should be handled by BaseRadioTransportFactory") - } + // Fallback to legacy factory for Serial, Mocks, and NOPs + return interfaceFactory.value.createInterface(address, service) } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt new file mode 100644 index 000000000..f33cedfae --- /dev/null +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.koin.core.annotation.Single +import org.meshtastic.core.model.InterfaceId +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioTransport + +/** + * Entry point for create radio backend instances given a specific address. + * + * This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest" + * of the address (which varies per implementation). + */ +@Single +class InterfaceFactory( + private val nopInterfaceFactory: NopInterfaceFactory, + private val mockSpec: Lazy, + private val serialSpec: Lazy, + private val tcpSpec: Lazy, +) { + internal val nopInterface by lazy { nopInterfaceFactory.create("") } + + private val specMap: Map> + get() = + mapOf( + InterfaceId.MOCK to mockSpec.value, + InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory), + InterfaceId.SERIAL to serialSpec.value, + InterfaceId.TCP to tcpSpec.value, + ) + + fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest" + + fun createInterface(address: String, service: RadioInterfaceService): RadioTransport { + val (spec, rest) = splitAddress(address) + return spec?.createInterface(rest, service) ?: nopInterface + } + + fun addressValid(address: String?): Boolean = address?.let { + val (spec, rest) = splitAddress(it) + spec?.addressValid(rest) + } ?: false + + private fun splitAddress(address: String): Pair?, String> { + if (address.isEmpty()) return Pair(null, "") + val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it] } + val rest = address.substring(1) + return Pair(c, rest) + } +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt similarity index 76% rename from core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt rename to core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt index 0f7985276..e57c4a446 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt @@ -17,39 +17,40 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.network.repository.SerialConnection import org.meshtastic.core.network.repository.SerialConnectionListener import org.meshtastic.core.network.repository.UsbRepository -import org.meshtastic.core.network.transport.HeartbeatSender -import org.meshtastic.core.repository.RadioTransportCallback +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio import java.util.concurrent.atomic.AtomicReference -/** An Android USB/serial [RadioTransport] implementation. */ -class SerialRadioTransport( - callback: RadioTransportCallback, - scope: CoroutineScope, +/** An interface that assumes we are talking to a meshtastic device via USB serial */ +class SerialInterface( + service: RadioInterfaceService, private val usbRepository: UsbRepository, private val address: String, -) : StreamTransport(callback, scope) { +) : StreamInterface(service) { private var connRef = AtomicReference() - private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]") - - override fun start() { + init { connect() } - override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) { + override fun onDeviceDisconnect(waitForStopped: Boolean) { connRef.get()?.close(waitForStopped) - super.onDeviceDisconnect(waitForStopped, isPermanent) + super.onDeviceDisconnect(waitForStopped) } override fun connect() { val deviceMap = usbRepository.serialDevices.value - val device = deviceMap[address] ?: deviceMap.values.firstOrNull() + val device = + if (deviceMap.containsKey(address)) { + deviceMap[address]!! + } else { + deviceMap.map { (_, driver) -> driver }.firstOrNull() + } if (device == null) { Logger.e { "[$address] Serial device not found at address" } } else { @@ -108,10 +109,7 @@ class SerialRadioTransport( "Uptime: ${uptime}ms, " + "Packets RX: $packetsReceived ($bytesReceived bytes)" } - // USB unplug / cable error is transient — the transport will reconnect when - // the device is replugged or the OS re-enumerates the port. Only an explicit - // close() (user disconnects) should signal a permanent disconnect. - onDeviceDisconnect(waitForStopped = false, isPermanent = false) + onDeviceDisconnect(false) } }, ) @@ -123,9 +121,14 @@ class SerialRadioTransport( } override fun keepAlive() { - // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the serial - // link is alive and keep the local node's lastHeard timestamp current. - scope.handledLaunch { heartbeatSender.sendHeartbeat() } + // Send a ToRadio heartbeat so the firmware resets its idle timer and responds with + // a FromRadio queueStatus — proving the serial link is alive. Without this, the + // serial transport has no way to detect a silently dead device (battery depleted, + // firmware crash without the `rebooted` flag). The queueStatus response also feeds + // into MeshMessageProcessorImpl.refreshLocalNodeLastHeard() to keep the local + // node's lastHeard timestamp current. + Logger.d { "[$address] Serial keepAlive — sending heartbeat" } + handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode()) } override fun sendBytes(p: ByteArray) { diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt new file mode 100644 index 000000000..f8c53313b --- /dev/null +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.koin.core.annotation.Single +import org.meshtastic.core.network.repository.UsbRepository +import org.meshtastic.core.repository.RadioInterfaceService + +/** Factory for creating `SerialInterface` instances. */ +@Single +class SerialInterfaceFactory(private val usbRepository: UsbRepository) { + fun create(rest: String, service: RadioInterfaceService): SerialInterface = + SerialInterface(service, usbRepository, rest) +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt new file mode 100644 index 000000000..8597fd060 --- /dev/null +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import android.hardware.usb.UsbManager +import com.hoho.android.usbserial.driver.UsbSerialDriver +import org.koin.core.annotation.Single +import org.meshtastic.core.network.repository.UsbRepository +import org.meshtastic.core.repository.RadioInterfaceService + +/** Serial/USB interface backend implementation. */ +@Single +class SerialInterfaceSpec( + private val factory: SerialInterfaceFactory, + private val usbManager: UsbManager, + private val usbRepository: UsbRepository, +) : InterfaceSpec { + override fun createInterface(rest: String, service: RadioInterfaceService): SerialInterface = + factory.create(rest, service) + + override fun addressValid(rest: String): Boolean { + usbRepository.serialDevices.value.filterValues { usbManager.hasPermission(it.device) } + findSerial(rest)?.let { d -> + return usbManager.hasPermission(d.device) + } + return false + } + + internal fun findSerial(rest: String): UsbSerialDriver? { + val deviceMap = usbRepository.serialDevices.value + return if (deviceMap.containsKey(rest)) { + deviceMap[rest]!! + } else { + deviceMap.map { (_, driver) -> driver }.firstOrNull() + } + } +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt new file mode 100644 index 000000000..003294448 --- /dev/null +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.RadioInterfaceService + +/** Factory for creating `TCPInterface` instances. */ +@Single +class TCPInterfaceFactory(private val dispatchers: CoroutineDispatchers) { + fun create(rest: String, service: RadioInterfaceService): TCPInterface = TCPInterface(service, dispatchers, rest) +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt new file mode 100644 index 000000000..2539bc13c --- /dev/null +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.koin.core.annotation.Single +import org.meshtastic.core.repository.RadioInterfaceService + +/** TCP interface backend implementation. */ +@Single +class TCPInterfaceSpec(private val factory: TCPInterfaceFactory) : InterfaceSpec { + override fun createInterface(rest: String, service: RadioInterfaceService): TCPInterface = + factory.create(rest, service) +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt index d8b14be03..b2ccf6545 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt @@ -87,11 +87,6 @@ internal class SerialConnectionImpl( port.open(usbDeviceConnection) port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) - - // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as - // present and starts its serial-side Meshtastic protocol. Empirically, omitting these - // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at - // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion. port.dtr = true port.rts = true diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt new file mode 100644 index 000000000..720d2a522 --- /dev/null +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.repository + +import android.annotation.SuppressLint +import java.security.cert.X509Certificate +import javax.net.ssl.X509TrustManager + +@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") +@Suppress("EmptyFunctionBlock") +class TrustAllX509TrustManager : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + + override fun checkServerTrusted(chain: Array?, authType: String?) {} + + override fun getAcceptedIssuers(): Array = arrayOf() +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt index c5080ec14..b4773dff3 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt @@ -54,7 +54,9 @@ class UsbRepository( _serialDevices .mapLatest { serialDevices -> val serialProber = usbSerialProberLazy.value - buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } } + buildMap { + serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } } + } } .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) @@ -81,8 +83,6 @@ class UsbRepository( processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() } } - private suspend fun refreshStateInternal() = withContext(dispatchers.default) { - val devices = usbManagerLazy.value?.deviceList ?: emptyMap() - _serialDevices.emit(devices) - } + private suspend fun refreshStateInternal() = + withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt deleted file mode 100644 index 87c317024..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network - -/** - * Shared HTTP client configuration constants used by both Android and Desktop Ktor `HttpClient` setups. - * - * These values are consumed by the platform-specific Koin modules (`NetworkModule` on Android, `DesktopKoinModule` on - * Desktop) when installing [io.ktor.client.plugins.HttpTimeout] and [io.ktor.client.plugins.HttpRequestRetry]. - */ -object HttpClientDefaults { - /** Timeout in milliseconds for connect, request, and socket operations. */ - const val TIMEOUT_MS = 30_000L - - /** Maximum number of automatic retries on server errors (5xx). */ - const val MAX_RETRIES = 3 - - /** Base URL for the Meshtastic public API. Installed via the `DefaultRequest` plugin. */ - const val API_BASE_URL = "https://api.meshtastic.org/" -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt deleted file mode 100644 index cabeb977a..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network - -import co.touchlab.kermit.Logger -import io.ktor.client.plugins.logging.Logger as KtorLogger - -/** - * Bridges Ktor's HTTP client logging to [Kermit][Logger] so HTTP request/response events appear in the standard app - * logs rather than going to [System.out] via Ktor's default [io.ktor.client.plugins.logging.Logger.DEFAULT]. - * - * Usage: - * ``` - * HttpClient(engine) { - * install(Logging) { - * logger = KermitHttpLogger - * level = LogLevel.HEADERS - * } - * } - * ``` - */ -object KermitHttpLogger : KtorLogger { - override fun log(message: String) { - Logger.d { message } - } -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt index 55856abf9..2c5a02784 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt @@ -38,41 +38,40 @@ abstract class BaseRadioTransportFactory( override fun isAddressValid(address: String?): Boolean { val spec = address?.firstOrNull() ?: return false - return when (spec) { - InterfaceId.TCP.id, - InterfaceId.SERIAL.id, - InterfaceId.BLUETOOTH.id, - InterfaceId.MOCK.id, - '!', - -> true - else -> isPlatformAddressValid(address) - } + return spec in + listOf(InterfaceId.TCP.id, InterfaceId.SERIAL.id, InterfaceId.BLUETOOTH.id, InterfaceId.MOCK.id) || + spec == '!' || + isPlatformAddressValid(address) } protected open fun isPlatformAddressValid(address: String): Boolean = false override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest" - override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport { - val transport = - when { - address.startsWith(InterfaceId.BLUETOOTH.id) || address.startsWith("!") -> { - val bleAddress = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()).removePrefix("!") - BleRadioTransport( - scope = service.serviceScope, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = bleAddress, - ) - } - else -> createPlatformTransport(address, service) - } - transport.start() - return transport + override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport = when { + address.startsWith(InterfaceId.BLUETOOTH.id) -> { + BleRadioInterface( + serviceScope = service.serviceScope, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + service = service, + address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()), + ) + } + address.startsWith("!") -> { + BleRadioInterface( + serviceScope = service.serviceScope, + scanner = scanner, + bluetoothRepository = bluetoothRepository, + connectionFactory = connectionFactory, + service = service, + address = address.removePrefix("!"), + ) + } + else -> createPlatformTransport(address, service) } - /** Delegate to platform for Mock, TCP, or Serial/USB transports. */ + /** Delegate to platform for Mock, TCP, or Serial/USB interfaces. */ protected abstract fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt new file mode 100644 index 000000000..9942eec87 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt @@ -0,0 +1,541 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught") + +package org.meshtastic.core.network.radio + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import org.meshtastic.core.ble.BleConnection +import org.meshtastic.core.ble.BleConnectionFactory +import org.meshtastic.core.ble.BleConnectionState +import org.meshtastic.core.ble.BleDevice +import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.ble.BleWriteType +import org.meshtastic.core.ble.BluetoothRepository +import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID +import org.meshtastic.core.ble.retryBleOperation +import org.meshtastic.core.ble.toMeshtasticRadioProfile +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.RadioNotConnectedException +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioTransport +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio +import kotlin.concurrent.Volatile +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.Duration.Companion.seconds + +private const val SCAN_RETRY_COUNT = 3 +private const val SCAN_RETRY_DELAY_MS = 1000L +private const val CONNECTION_TIMEOUT_MS = 15_000L +private const val RECONNECT_FAILURE_THRESHOLD = 3 +private const val RECONNECT_BASE_DELAY_MS = 5_000L +private const val RECONNECT_MAX_DELAY_MS = 60_000L +private const val RECONNECT_MAX_FAILURES = 10 + +/** + * Minimum milliseconds a BLE connection must stay up before we consider it "stable" and reset + * [BleRadioInterface.consecutiveFailures]. Without this, a device at the edge of BLE range can repeatedly connect for a + * fraction of a second and drop — each brief connection resets the failure counter so [RECONNECT_FAILURE_THRESHOLD] is + * never reached, and the app never signals [ConnectionState.DeviceSleep]. + * + * The value (5 s) is long enough that only connections that survive past the initial GATT setup are treated as genuine, + * but short enough that normal reconnects after light-sleep still reset the counter promptly. + */ +private const val MIN_STABLE_CONNECTION_MS = 5_000L + +/** + * Returns the reconnect backoff delay in milliseconds for a given consecutive failure count. + * + * Backoff schedule: 1 failure → 5 s 2 failures → 10 s 3 failures → 20 s 4 failures → 40 s 5+ failures → 60 s (capped) + */ +internal fun computeReconnectBackoffMs(consecutiveFailures: Int): Long { + if (consecutiveFailures <= 0) return RECONNECT_BASE_DELAY_MS + return minOf(RECONNECT_BASE_DELAY_MS * (1L shl (consecutiveFailures - 1).coerceAtMost(4)), RECONNECT_MAX_DELAY_MS) +} + +// Milliseconds to wait after launching characteristic observations before triggering the +// Meshtastic handshake. Both fromRadio and logRadio observation flows write the CCCD +// asynchronously via Kable's GATT queue. Without this settle window the want_config_id +// burst from the radio can arrive before notifications are enabled, causing the first +// handshake attempt to look like a stall. +private const val CCCD_SETTLE_MS = 50L + +private val SCAN_TIMEOUT = 5.seconds +private val GATT_CLEANUP_TIMEOUT = 5.seconds + +/** + * A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable). + * + * This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including: + * - Bonding and discovery. + * - Automatic reconnection logic. + * - MTU and connection parameter monitoring. + * - Routing raw byte packets between the radio and [RadioInterfaceService]. + * + * @param serviceScope The coroutine scope to use for launching coroutines. + * @param scanner The BLE scanner. + * @param bluetoothRepository The Bluetooth repository. + * @param connectionFactory The BLE connection factory. + * @param service The [RadioInterfaceService] to use for handling radio events. + * @param address The BLE address of the device to connect to. + */ +class BleRadioInterface( + private val serviceScope: CoroutineScope, + private val scanner: BleScanner, + private val bluetoothRepository: BluetoothRepository, + private val connectionFactory: BleConnectionFactory, + private val service: RadioInterfaceService, + val address: String, +) : RadioTransport { + + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" } + serviceScope.launch { + try { + bleConnection.disconnect() + } catch (e: Exception) { + Logger.w(e) { "[$address] Failed to disconnect in exception handler" } + } + } + val (isPermanent, msg) = throwable.toDisconnectReason() + service.onDisconnect(isPermanent, errorMessage = msg) + } + + private val connectionScope: CoroutineScope = + CoroutineScope( + serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler, + ) + private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address) + private val writeMutex: Mutex = Mutex() + + private var connectionStartTime: Long = 0 + private var packetsReceived: Int = 0 + private var packetsSent: Int = 0 + private var bytesReceived: Long = 0 + private var bytesSent: Long = 0 + + @Volatile private var isFullyConnected = false + private var connectionJob: Job? = null + private var consecutiveFailures = 0 + + @OptIn(ExperimentalAtomicApi::class) + private val heartbeatNonce = AtomicInt(0) + + init { + connect() + } + + // --- Connection & Discovery Logic --- + + /** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */ + private suspend fun findDevice(): BleDevice { + bluetoothRepository.state.value.bondedDevices + .firstOrNull { it.address.equals(address, ignoreCase = true) } + ?.let { + return it + } + + Logger.i { "[$address] Device not found in bonded list, scanning" } + + repeat(SCAN_RETRY_COUNT) { attempt -> + try { + val d = + withTimeoutOrNull(SCAN_TIMEOUT) { + scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first { + it.address.equals(address, ignoreCase = true) + } + } + if (d != null) return d + } catch (e: Exception) { + Logger.v(e) { "[$address] Scan attempt failed or timed out" } + } + + if (attempt < SCAN_RETRY_COUNT - 1) { + delay(SCAN_RETRY_DELAY_MS) + } + } + + throw RadioNotConnectedException("Device not found at address $address") + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + private fun connect() { + connectionJob = + connectionScope.launch { + while (isActive) { + try { + // Allow any pending background disconnects to complete and the Android BLE stack + // to settle before we attempt a new connection. + @Suppress("MagicNumber") + val connectDelayMs = 1000L + delay(connectDelayMs) + + connectionStartTime = nowMillis + Logger.i { "[$address] BLE connection attempt started" } + + val device = findDevice() + + // Ensure the device is bonded before connecting. On Android, the + // firmware may require an encrypted link (pairing mode != NO_PIN). + // Without an explicit bond the GATT connection will fail with + // insufficient-authentication (status 5) or the dreaded status 133. + // On Desktop/JVM this is a no-op since the OS handles pairing during + // the GATT connection when the peripheral requires it. + if (!bluetoothRepository.isBonded(address)) { + Logger.i { "[$address] Device not bonded, initiating bonding" } + @Suppress("TooGenericExceptionCaught") + try { + bluetoothRepository.bond(device) + Logger.i { "[$address] Bonding successful" } + } catch (e: Exception) { + Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" } + } + } + + var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS) + + if (state !is BleConnectionState.Connected) { + // Kable on Android occasionally fails the first connection attempt with + // NotConnectedException if the previous peripheral wasn't fully cleaned + // up by the OS. A quick retry resolves it. + Logger.d { "[$address] First connection attempt failed, retrying in 1.5s" } + @Suppress("MagicNumber") + delay(1500L) + state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS) + } + + if (state !is BleConnectionState.Connected) { + throw RadioNotConnectedException("Failed to connect to device at address $address") + } + + // Connection succeeded — only reset the failure counter if the + // connection stays up long enough. See MIN_STABLE_CONNECTION_MS. + val gattConnectedAt = nowMillis + isFullyConnected = true + onConnected() + + // Use coroutineScope so that the connectionState listener is scoped to this + // iteration only. When the inner scope exits (on disconnect), the listener is + // cancelled automatically before the next reconnect cycle starts a fresh one. + coroutineScope { + bleConnection.connectionState + .onEach { s -> + if (s is BleConnectionState.Disconnected && isFullyConnected) { + isFullyConnected = false + onDisconnected() + } + } + .catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } } + .launchIn(this) + + discoverServicesAndSetupCharacteristics() + + // Suspend here until Kable drops the connection + bleConnection.connectionState.first { it is BleConnectionState.Disconnected } + } + + Logger.i { "[$address] BLE connection dropped, preparing to reconnect" } + + // Only reset the failure counter if the connection was stable (lasted + // longer than MIN_STABLE_CONNECTION_MS). A connection that drops within + // seconds typically means the device is at the edge of BLE range or + // powered off — the Android BLE stack may briefly "connect" to a cached + // GATT profile before realising the device is gone. Without this guard, + // the failure counter resets on every brief connect, preventing us from + // ever reaching RECONNECT_FAILURE_THRESHOLD and signalling DeviceSleep. + val connectionUptime = nowMillis - gattConnectedAt + if (connectionUptime >= MIN_STABLE_CONNECTION_MS) { + consecutiveFailures = 0 + } else { + consecutiveFailures++ + Logger.w { + "[$address] Connection lasted only ${connectionUptime}ms " + + "(< ${MIN_STABLE_CONNECTION_MS}ms) — treating as failure " + + "(consecutive failures: $consecutiveFailures)" + } + if (consecutiveFailures >= RECONNECT_MAX_FAILURES) { + Logger.e { "[$address] Giving up after $consecutiveFailures unstable connections" } + service.onDisconnect( + isPermanent = true, + errorMessage = "Device unreachable (unstable connection)", + ) + return@launch + } + if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) { + service.onDisconnect( + isPermanent = false, + errorMessage = "Device unreachable (unstable connection)", + ) + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + Logger.d { "[$address] BLE connection coroutine cancelled" } + throw e + } catch (e: Exception) { + val failureTime = nowMillis - connectionStartTime + consecutiveFailures++ + Logger.w(e) { + "[$address] Failed to connect to device after ${failureTime}ms " + + "(consecutive failures: $consecutiveFailures)" + } + + // After exceeding the max failure limit, give up permanently to stop + // draining battery on a device that is genuinely offline. The user + // must manually reconnect from the connections screen. + if (consecutiveFailures >= RECONNECT_MAX_FAILURES) { + Logger.e { "[$address] Giving up after $consecutiveFailures consecutive failures" } + val (_, msg) = e.toDisconnectReason() + service.onDisconnect(isPermanent = true, errorMessage = msg) + return@launch + } + + // At the failure threshold, signal DeviceSleep so + // MeshConnectionManagerImpl can start its sleep timeout. + if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) { + handleFailure(e) + } + + // Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s. + // Reduces BLE stack pressure and battery drain when the device is genuinely + // out of range, while still recovering quickly from transient drops. + val backoffMs = computeReconnectBackoffMs(consecutiveFailures) + Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" } + delay(backoffMs) + } + } + } + } + + private suspend fun onConnected() { + try { + bleConnection.deviceFlow.first()?.let { device -> + val rssi = retryBleOperation(tag = address) { device.readRssi() } + Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" } + } + } catch (e: Exception) { + Logger.w(e) { "[$address] Failed to read initial connection RSSI" } + } + } + + private fun onDisconnected() { + radioService = null + + val uptime = + if (connectionStartTime > 0) { + nowMillis - connectionStartTime + } else { + 0 + } + Logger.i { + "[$address] BLE disconnected - " + + "Uptime: ${uptime}ms, " + + "Packets RX: $packetsReceived ($bytesReceived bytes), " + + "Packets TX: $packetsSent ($bytesSent bytes)" + } + // Signal DeviceSleep immediately so the UI reflects the disconnect while the + // reconnect loop continues in the background. The previous approach suppressed + // this signal until RECONNECT_FAILURE_THRESHOLD consecutive failures, leaving the + // UI stuck on "Connected" for 35+ seconds after the device disappeared. + service.onDisconnect(isPermanent = false) + } + + private suspend fun discoverServicesAndSetupCharacteristics() { + try { + bleConnection.profile(serviceUuid = SERVICE_UUID) { service -> + val radioService = service.toMeshtasticRadioProfile() + + // Wire up notifications + radioService.fromRadio + .onEach { packet -> + Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" } + dispatchPacket(packet) + } + .catch { e -> + Logger.w(e) { "[$address] Error in fromRadio flow" } + handleFailure(e) + } + .launchIn(this) + + radioService.logRadio + .onEach { packet -> + Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" } + dispatchPacket(packet) + } + .catch { e -> + Logger.w(e) { "[$address] Error in logRadio flow" } + handleFailure(e) + } + .launchIn(this) + + // Store reference for handleSendToRadio + this@BleRadioInterface.radioService = radioService + + Logger.i { "[$address] Profile service active and characteristics subscribed" } + + // Give Kable's async CCCD writes time to complete before triggering the + // Meshtastic handshake. The fromRadio/logRadio observation flows register + // notifications through the GATT queue asynchronously. Without this settle + // window, the want_config_id burst arrives before notifications are enabled. + delay(CCCD_SETTLE_MS) + + // Log negotiated MTU for diagnostics + val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) + Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" } + + this@BleRadioInterface.service.onConnect() + } + } catch (e: Exception) { + Logger.w(e) { "[$address] Profile service discovery or operation failed" } + // Ensure the peripheral is disconnected so the outer reconnect loop sees a clean + // Disconnected state. Do NOT call handleFailure here — the reconnect loop tracks + // consecutive failures and calls handleFailure after RECONNECT_FAILURE_THRESHOLD, + // preventing premature onDisconnect signals to the service on transient errors. + try { + bleConnection.disconnect() + } catch (ignored: Exception) { + Logger.w(ignored) { "[$address] disconnect() failed after profile error" } + } + } + } + + @Volatile private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null + + // --- RadioTransport Implementation --- + + /** + * Sends a packet to the radio with retry support. + * + * @param p The packet to send. + */ + override fun handleSendToRadio(p: ByteArray) { + val currentService = radioService + if (currentService != null) { + connectionScope.launch { + writeMutex.withLock { + try { + retryBleOperation(tag = address) { currentService.sendToRadio(p) } + packetsSent++ + bytesSent += p.size + Logger.v { + "[$address] Wrote packet #$packetsSent " + + "to toRadio (${p.size} bytes, total TX: $bytesSent bytes)" + } + } catch (e: Exception) { + Logger.w(e) { + "[$address] Failed to write packet to toRadioCharacteristic after " + + "$packetsSent successful writes" + } + handleFailure(e) + } + } + } + } else { + Logger.w { "[$address] toRadio characteristic unavailable, can't send data" } + } + } + + @OptIn(ExperimentalAtomicApi::class) + override fun keepAlive() { + // Send a ToRadio heartbeat so the firmware resets its power-saving idle timer. + // The firmware only resets the timer on writes to the TORADIO characteristic; a + // BLE-level GATT keepalive is invisible to it. Without this the device may enter + // light-sleep and drop the BLE connection after ~60 s of application inactivity. + // + // Each heartbeat uses a distinct nonce to vary the wire bytes, preventing the + // firmware's per-connection duplicate-write filter from silently dropping it. + val nonce = heartbeatNonce.fetchAndAdd(1) + Logger.v { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" } + handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode()) + } + + /** Closes the connection to the device. */ + override fun close() { + val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0 + Logger.i { + "[$address] Disconnecting. " + + "Uptime: ${uptime}ms, " + + "Packets RX: $packetsReceived ($bytesReceived bytes), " + + "Packets TX: $packetsSent ($bytesSent bytes)" + } + // Cancel the connection scope to break the while(isActive) reconnect loop. + connectionScope.cancel("close() called") + // GATT cleanup must survive serviceScope cancellation. SharedRadioInterfaceService calls + // close() and then immediately cancels serviceScope — a coroutine launched on serviceScope + // may never be dispatched, leaving the BluetoothGatt object leaked (causes GATT 133 on the + // next connect attempt). GlobalScope is the correct tool here: the cleanup is short-lived, + // fire-and-forget, and must outlive any application-managed scope. + // onDisconnect is handled by SharedRadioInterfaceService.stopInterfaceLocked() directly. + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch { + try { + withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "[$address] Failed to disconnect in close()" } + } + } + } + + private fun dispatchPacket(packet: ByteArray) { + packetsReceived++ + bytesReceived += packet.size + Logger.v { + "[$address] Dispatching packet #$packetsReceived " + + "(${packet.size} bytes, total RX: $bytesReceived bytes)" + } + service.handleFromRadio(packet) + } + + private fun handleFailure(throwable: Throwable) { + val (isPermanent, msg) = throwable.toDisconnectReason() + service.onDisconnect(isPermanent, errorMessage = msg) + } + + private fun Throwable.toDisconnectReason(): Pair { + val isPermanent = + this::class.simpleName == "BluetoothUnavailableException" || + this::class.simpleName == "ManagerClosedException" + val msg = + when { + this is RadioNotConnectedException -> this.message ?: "Device not found" + this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing" + this::class.simpleName == "GattException" -> "GATT Error: ${this.message}" + else -> this.message ?: this::class.simpleName ?: "Unknown" + } + return Pair(isPermanent, msg) + } +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt deleted file mode 100644 index f2ba25804..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt +++ /dev/null @@ -1,457 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught") - -package org.meshtastic.core.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -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 -import org.meshtastic.core.ble.BleConnectionState -import org.meshtastic.core.ble.BleDevice -import org.meshtastic.core.ble.BleScanner -import org.meshtastic.core.ble.BleWriteType -import org.meshtastic.core.ble.BluetoothRepository -import org.meshtastic.core.ble.DisconnectReason -import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID -import org.meshtastic.core.ble.MeshtasticRadioProfile -import org.meshtastic.core.ble.classifyBleException -import org.meshtastic.core.ble.retryBleOperation -import org.meshtastic.core.ble.toMeshtasticRadioProfile -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.RadioNotConnectedException -import org.meshtastic.core.network.transport.HeartbeatSender -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportCallback -import kotlin.concurrent.Volatile -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -private const val SCAN_RETRY_COUNT = 3 -private val SCAN_RETRY_DELAY = 1.seconds -private val CONNECTION_TIMEOUT = 15.seconds - -/** - * Delay after writing a heartbeat before re-polling FROMRADIO. - * - * The ESP32 firmware processes TORADIO writes asynchronously (NimBLE callback → FreeRTOS main task queue → - * `handleToRadio()` → `heartbeatReceived = true`). The immediate drain trigger in - * [KableMeshtasticRadioProfile.sendToRadio] fires before this completes, so the `queueStatus` response is not yet - * available. 200 ms is well above observed ESP32 task scheduling latency (~10–50 ms) while remaining imperceptible to - * the user. - */ -private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds - -private val SCAN_TIMEOUT = 5.seconds -private val GATT_CLEANUP_TIMEOUT = 5.seconds - -/** - * A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable). - * - * This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including: - * - Bonding and discovery. - * - Automatic reconnection logic. - * - MTU and connection parameter monitoring. - * - Routing raw byte packets between the radio and [RadioTransportCallback]. - * - * @param scope The coroutine scope to use for launching coroutines. - * @param scanner The BLE scanner. - * @param bluetoothRepository The Bluetooth repository. - * @param connectionFactory The BLE connection factory. - * @param callback The [RadioTransportCallback] to use for handling radio events. - * @param address The BLE address of the device to connect to. - */ -class BleRadioTransport( - private val scope: CoroutineScope, - private val scanner: BleScanner, - private val bluetoothRepository: BluetoothRepository, - private val connectionFactory: BleConnectionFactory, - private val callback: RadioTransportCallback, - internal val address: String, -) : RadioTransport { - - private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" } - scope.launch { - try { - bleConnection.disconnect() - } catch (e: Exception) { - Logger.w(e) { "[$address] Failed to disconnect in exception handler" } - } - } - val (isPermanent, msg) = throwable.toDisconnectReason() - callback.onDisconnect(isPermanent, errorMessage = msg) - } - - private val connectionScope: CoroutineScope = - CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + exceptionHandler) - private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address) - private val writeMutex: Mutex = Mutex() - - @Volatile private var connectionStartTime: Long = 0 - - @Volatile private var packetsReceived: Int = 0 - - @Volatile private var packetsSent: Int = 0 - - @Volatile private var bytesReceived: Long = 0 - - @Volatile private var bytesSent: Long = 0 - - @Volatile private var isFullyConnected = false - private var connectionJob: Job? = null - - // Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService) - // own the explicit-disconnect lifecycle and will close() us when the user picks a different device or - // toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s). - private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE) - - private val heartbeatSender = - HeartbeatSender( - sendToRadio = ::handleSendToRadio, - afterHeartbeat = { - delay(HEARTBEAT_DRAIN_DELAY) - radioService?.requestDrain() - }, - logTag = address, - ) - - override fun start() { - connect() - } - - // --- Connection & Discovery Logic --- - - /** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */ - private suspend fun findDevice(): BleDevice { - bluetoothRepository.state.value.bondedDevices - .firstOrNull { it.address.equals(address, ignoreCase = true) } - ?.let { - return it - } - - Logger.i { "[$address] Device not found in bonded list, scanning" } - - repeat(SCAN_RETRY_COUNT) { attempt -> - try { - val d = - withTimeoutOrNull(SCAN_TIMEOUT) { - scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first { - it.address.equals(address, ignoreCase = true) - } - } - if (d != null) return d - } catch (e: Exception) { - Logger.v(e) { "[$address] Scan attempt failed or timed out" } - } - - if (attempt < SCAN_RETRY_COUNT - 1) { - delay(SCAN_RETRY_DELAY) - } - } - - throw RadioNotConnectedException("Device not found at address $address") - } - - private fun connect() { - connectionJob = - connectionScope.launch { - reconnectPolicy.execute( - attempt = { - try { - attemptConnection() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - val failureTime = (nowMillis - connectionStartTime).milliseconds - Logger.w(e) { "[$address] Failed to connect after $failureTime" } - BleReconnectPolicy.Outcome.Failed(e) - } - }, - onTransientDisconnect = { error -> - val msg = error?.toDisconnectReason()?.second ?: "Device unreachable" - callback.onDisconnect(isPermanent = false, errorMessage = msg) - }, - onPermanentDisconnect = { error -> - val msg = error?.toDisconnectReason()?.second ?: "Device unreachable" - callback.onDisconnect(isPermanent = true, errorMessage = msg) - }, - ) - } - } - - /** - * Performs a single BLE connect-and-wait cycle. - * - * Finds the device, bonds if needed, connects, discovers services, and waits for disconnect. Returns a - * [BleReconnectPolicy.Outcome] describing how the connection ended. - */ - @Suppress("CyclomaticComplexMethod") - private suspend fun attemptConnection(): BleReconnectPolicy.Outcome { - connectionStartTime = nowMillis - Logger.i { "[$address] BLE connection attempt started" } - - val device = findDevice() - - // Bond before connecting: firmware may require an encrypted link, - // and without a bond Android fails with status 5 or 133. - // No-op on Desktop/JVM where the OS handles pairing automatically. - if (!bluetoothRepository.isBonded(address)) { - Logger.i { "[$address] Device not bonded, initiating bonding" } - @Suppress("TooGenericExceptionCaught") - try { - bluetoothRepository.bond(device) - Logger.i { "[$address] Bonding successful" } - } catch (e: Exception) { - Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" } - } - } - - val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) - - if (state !is BleConnectionState.Connected) { - throw RadioNotConnectedException("Failed to connect to device at address $address") - } - - val gattConnectedAt = nowMillis - isFullyConnected = true - onConnected() - - // Scope the connectionState listener to this iteration so it's - // cancelled automatically before the next reconnect cycle. - var disconnectReason: DisconnectReason = DisconnectReason.Unknown - coroutineScope { - bleConnection.connectionState - .onEach { s -> - if (s is BleConnectionState.Disconnected && isFullyConnected) { - isFullyConnected = false - disconnectReason = s.reason - onDisconnected() - } - } - .catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } } - .launchIn(this) - - discoverServicesAndSetupCharacteristics() - - bleConnection.connectionState.first { it is BleConnectionState.Disconnected } - } - - Logger.i { "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" } - - val wasIntentional = disconnectReason is DisconnectReason.LocalDisconnect - val connectionUptime = (nowMillis - gattConnectedAt).milliseconds - val wasStable = connectionUptime >= reconnectPolicy.minStableConnection - - if (!wasStable && !wasIntentional) { - Logger.w { - "[$address] Connection lasted only $connectionUptime " + - "(< ${reconnectPolicy.minStableConnection}) — treating as unstable" - } - } - - return BleReconnectPolicy.Outcome.Disconnected(wasStable = wasStable, wasIntentional = wasIntentional) - } - - private suspend fun onConnected() { - try { - bleConnection.deviceFlow.first()?.let { device -> - val rssi = retryBleOperation(tag = address) { device.readRssi() } - Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" } - } - } catch (e: Exception) { - Logger.w(e) { "[$address] Failed to read initial connection RSSI" } - } - } - - private fun onDisconnected() { - radioService = null - Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" } - // Signal immediately so the UI reflects the disconnect while reconnect continues. - callback.onDisconnect(isPermanent = false) - } - - private suspend fun discoverServicesAndSetupCharacteristics() { - try { - bleConnection.profile(serviceUuid = SERVICE_UUID) { service -> - val radioService = service.toMeshtasticRadioProfile() - - radioService.fromRadio - .onEach { packet -> - Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" } - dispatchPacket(packet) - } - .catch { e -> - Logger.w(e) { "[$address] Error in fromRadio flow" } - handleFailure(e) - } - .launchIn(this) - - radioService.logRadio - .onEach { packet -> - Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" } - dispatchPacket(packet) - } - .catch { e -> - Logger.w(e) { "[$address] Error in logRadio flow" } - handleFailure(e) - } - .launchIn(this) - - this@BleRadioTransport.radioService = radioService - - Logger.i { "[$address] Profile service active and characteristics subscribed" } - - // Wait for FROMNUM CCCD write before triggering the Meshtastic handshake. - radioService.awaitSubscriptionReady() - - // Log negotiated MTU for diagnostics - val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) - Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" } - - this@BleRadioTransport.callback.onConnect() - } - } catch (e: Exception) { - Logger.w(e) { "[$address] Profile service discovery or operation failed" } - // Disconnect to let the outer reconnect loop see a clean Disconnected state. - // Do NOT call handleFailure here — the reconnect loop owns failure counting. - try { - bleConnection.disconnect() - } catch (ignored: Exception) { - Logger.w(ignored) { "[$address] disconnect() failed after profile error" } - } - } - } - - @Volatile private var radioService: MeshtasticRadioProfile? = null - - // --- RadioTransport Implementation --- - - /** - * Sends a packet to the radio with retry support. - * - * @param p The packet to send. - */ - override fun handleSendToRadio(p: ByteArray) { - val currentService = radioService - if (currentService != null) { - connectionScope.launch { - writeMutex.withLock { - try { - retryBleOperation(tag = address) { currentService.sendToRadio(p) } - packetsSent++ - bytesSent += p.size - Logger.v { - "[$address] Wrote packet #$packetsSent " + - "to toRadio (${p.size} bytes, total TX: $bytesSent bytes)" - } - } catch (e: Exception) { - Logger.w(e) { - "[$address] Failed to write packet to toRadioCharacteristic after " + - "$packetsSent successful writes" - } - handleFailure(e) - } - } - } - } else { - Logger.w { "[$address] toRadio characteristic unavailable, can't send data" } - } - } - - override fun keepAlive() { - // Delegate to HeartbeatSender which sends a ToRadio heartbeat with a unique nonce - // so the firmware resets its power-saving idle timer. After sending, it schedules - // a delayed re-drain to pick up the queueStatus response. - connectionScope.launch { heartbeatSender.sendHeartbeat() } - } - - /** Closes the connection to the device. */ - override suspend fun close() { - Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" } - connectionScope.cancel("close() called") - // 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) { - Logger.w(e) { "[$address] Failed to disconnect in close()" } - } - } - } - - private fun dispatchPacket(packet: ByteArray) { - packetsReceived++ - bytesReceived += packet.size - Logger.v { - "[$address] Dispatching packet #$packetsReceived " + - "(${packet.size} bytes, total RX: $bytesReceived bytes)" - } - callback.handleFromRadio(packet) - } - - private fun handleFailure(throwable: Throwable) { - val (isPermanent, msg) = throwable.toDisconnectReason() - callback.onDisconnect(isPermanent, errorMessage = msg) - } - - /** Formats a one-line session statistics summary for logging. */ - private fun formatSessionStats(): String { - val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0 - return "Uptime: ${uptime}ms, " + - "Packets RX: $packetsReceived ($bytesReceived bytes), " + - "Packets TX: $packetsSent ($bytesSent bytes)" - } - - private fun Throwable.toDisconnectReason(): Pair { - classifyBleException()?.let { - return it.isPermanent to it.message - } - - val msg = - when (this) { - is RadioNotConnectedException -> this.message ?: "Device not found" - is NoSuchElementException, - is IllegalArgumentException, - -> "Required characteristic missing" - else -> this.message ?: this::class.simpleName ?: "Unknown" - } - return false to msg - } -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt deleted file mode 100644 index e4d250796..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlin.coroutines.coroutineContext -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -/** - * Encapsulates the BLE reconnection policy with exponential backoff. - * - * The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep). - * When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns; - * set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely. - * - * @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely - * @param failureThreshold after this many consecutive failures, signal a transient disconnect - * @param settleDelay delay before each connection attempt to let the BLE stack settle - * @param minStableConnection minimum time a connection must stay up to be considered "stable" - * @param backoffStrategy computes the backoff delay for a given failure count - */ -class BleReconnectPolicy( - private val maxFailures: Int = DEFAULT_MAX_FAILURES, - private val failureThreshold: Int = DEFAULT_FAILURE_THRESHOLD, - private val settleDelay: Duration = DEFAULT_SETTLE_DELAY, - /** Minimum time a connection must stay up to be considered "stable". Exposed for callers to compare uptime. */ - val minStableConnection: Duration = DEFAULT_MIN_STABLE_CONNECTION, - private val backoffStrategy: (attempt: Int) -> Duration = ::computeReconnectBackoff, -) { - /** Outcome of a single reconnect iteration. */ - sealed interface Outcome { - /** Connection attempt succeeded and then eventually disconnected. */ - data class Disconnected(val wasStable: Boolean, val wasIntentional: Boolean) : Outcome - - /** Connection attempt failed with an exception. */ - data class Failed(val error: Throwable) : Outcome - } - - /** Action the caller should take after the policy processes an outcome. */ - sealed interface Action { - /** Retry the connection after the specified backoff delay. */ - data class Retry(val backoff: Duration) : Action - - /** Signal a transient disconnect to higher layers. */ - data class SignalTransient(val backoff: Duration) : Action - - /** Give up permanently. */ - data object GiveUp : Action - - /** Continue immediately (e.g. after an intentional disconnect). */ - data object Continue : Action - } - - internal var consecutiveFailures: Int = 0 - private set - - /** Processes the outcome of a connection attempt and returns the action the caller should take. */ - fun processOutcome(outcome: Outcome): Action = when (outcome) { - is Outcome.Disconnected -> { - if (outcome.wasIntentional) { - consecutiveFailures = 0 - Action.Continue - } else if (outcome.wasStable) { - consecutiveFailures = 0 - Action.Continue - } else { - consecutiveFailures++ - Logger.w { "Unstable connection (consecutive failures: $consecutiveFailures)" } - evaluateFailure() - } - } - is Outcome.Failed -> { - consecutiveFailures++ - Logger.w { "Connection failed (consecutive failures: $consecutiveFailures)" } - evaluateFailure() - } - } - - private fun evaluateFailure(): Action { - if (consecutiveFailures >= maxFailures) { - return Action.GiveUp - } - val backoff = backoffStrategy(consecutiveFailures) - return if (consecutiveFailures >= failureThreshold) { - Action.SignalTransient(backoff) - } else { - Action.Retry(backoff) - } - } - - /** - * Runs the reconnect loop, calling [attempt] for each iteration. - * - * The [attempt] lambda should perform a single connect-and-wait cycle and return the [Outcome] when the connection - * drops or an error occurs. - * - * @param attempt performs a single connection attempt and returns the outcome - * @param onTransientDisconnect called when the policy decides to signal a transient disconnect - * @param onPermanentDisconnect called when the policy gives up permanently - */ - suspend fun execute( - attempt: suspend () -> Outcome, - onTransientDisconnect: suspend (Throwable?) -> Unit, - onPermanentDisconnect: suspend (Throwable?) -> Unit, - ) { - while (coroutineContext.isActive) { - delay(settleDelay) - - val outcome = attempt() - val lastError = (outcome as? Outcome.Failed)?.error - - when (val action = processOutcome(outcome)) { - is Action.Continue -> continue - is Action.Retry -> { - Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" } - delay(action.backoff) - } - is Action.SignalTransient -> { - onTransientDisconnect(lastError) - Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" } - delay(action.backoff) - } - is Action.GiveUp -> { - Logger.e { "Giving up after $consecutiveFailures consecutive failures" } - onPermanentDisconnect(lastError) - return - } - } - } - } - - companion object { - const val DEFAULT_MAX_FAILURES = 10 - const val DEFAULT_FAILURE_THRESHOLD = 3 - - /** - * Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side - * GATT session have time to settle. - * - * Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between - * disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the - * firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose - * to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more - * mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same. - */ - val DEFAULT_SETTLE_DELAY = 3.seconds - val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds - - internal val RECONNECT_BASE_DELAY = 5.seconds - internal val RECONNECT_MAX_DELAY = 60.seconds - internal const val BACKOFF_MAX_EXPONENT = 4 - } -} - -/** - * Returns the reconnect backoff delay for a given consecutive failure count. - * - * Backoff schedule: 1 failure → 5 s, 2 failures → 10 s, 3 failures → 20 s, 4 failures → 40 s, 5+ failures → 60 s - * (capped). - */ -internal fun computeReconnectBackoff(consecutiveFailures: Int): Duration { - if (consecutiveFailures <= 0) return BleReconnectPolicy.RECONNECT_BASE_DELAY - val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(BleReconnectPolicy.BACKOFF_MAX_EXPONENT) - return minOf(BleReconnectPolicy.RECONNECT_BASE_DELAY * multiplier, BleReconnectPolicy.RECONNECT_MAX_DELAY) -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt new file mode 100644 index 000000000..5354f5500 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.meshtastic.core.repository.RadioTransport + +/** + * Radio interface factory service provider interface. Each radio backend implementation needs to have a factory to + * create new instances. These instances are specific to a particular address. This interface defines a common API + * across all radio interfaces for obtaining implementation instances. + * + * This is primarily used in conjunction with Dagger assisted injection for each backend interface type. + */ +interface InterfaceFactorySpi { + fun create(rest: String): T +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt new file mode 100644 index 000000000..aec9ec667 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioTransport + +/** This interface defines the contract that all radio backend implementations must adhere to. */ +interface InterfaceSpec { + fun createInterface(rest: String, service: RadioInterfaceService): T + + /** Return true if this address is still acceptable. For BLE that means, still bonded */ + fun addressValid(rest: String): Boolean = true +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt similarity index 88% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt index f8edeaa73..4990ee7ab 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import okio.ByteString.Companion.encodeUtf8 import okio.ByteString.Companion.toByteString @@ -26,8 +25,8 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Channel import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.util.getInitials +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportCallback import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Config import org.meshtastic.proto.Data @@ -56,13 +55,9 @@ private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Co private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY) -/** A simulated transport that is used for testing in the simulator. */ +/** A simulated interface that is used for testing in the simulator */ @Suppress("detekt:TooManyFunctions", "detekt:MagicNumber") -class MockRadioTransport( - private val callback: RadioTransportCallback, - private val scope: CoroutineScope, - val address: String, -) : RadioTransport { +class MockInterface(private val service: RadioInterfaceService, val address: String) : RadioTransport { companion object { private const val MY_NODE = 0x42424242 @@ -73,22 +68,13 @@ class MockRadioTransport( // an infinite sequence of ints private val packetIdSequence = generateSequence { currentPacketId++ }.iterator() - override fun start() { - Logger.i { "Starting the mock transport" } - callback.onConnect() // Tell clients they can use the API + init { + Logger.i { "Starting the mock interface" } + service.onConnect() // Tell clients they can use the API } override fun handleSendToRadio(p: ByteArray) { val pr = ToRadio.ADAPTER.decode(p) - - // Intercept want_config handshake — send config response only when requested, - // mirroring the behaviour of real firmware which waits for want_config_id. - val wantConfigId = pr.want_config_id ?: 0 - if (wantConfigId != 0) { - sendConfigResponse(wantConfigId) - return - } - val packet = pr.packet if (packet != null) { sendQueueStatus(packet.id) @@ -97,10 +83,11 @@ class MockRadioTransport( val data = packet?.decoded when { + (pr.want_config_id ?: 0) != 0 -> sendConfigResponse(pr.want_config_id ?: 0) data != null && data.portnum == PortNum.ADMIN_APP -> handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload)) packet != null && packet.want_ack == true -> sendFakeAck(pr) - else -> Logger.i { "Ignoring data sent to mock transport $pr" } + else -> Logger.i { "Ignoring data sent to mock interface $pr" } } } @@ -140,12 +127,12 @@ class MockRadioTransport( ) } - else -> Logger.i { "Ignoring admin sent to mock transport $d" } + else -> Logger.i { "Ignoring admin sent to mock interface $d" } } } - override suspend fun close() { - Logger.i { "Closing the mock transport" } + override fun close() { + Logger.i { "Closing the mock interface" } } // / Generate a fake text message from a node @@ -292,7 +279,7 @@ class MockRadioTransport( Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId), ) - private fun sendQueueStatus(msgId: Int) = callback.handleFromRadio( + private fun sendQueueStatus(msgId: Int) = service.handleFromRadio( FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(), ) @@ -304,14 +291,14 @@ class MockRadioTransport( toIn, Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId), ) - callback.handleFromRadio(p.encode()) + service.handleFromRadio(p.encode()) } // / Send a fake ack packet back if the sender asked for want_ack - private fun sendFakeAck(pr: ToRadio) = scope.handledLaunch { + private fun sendFakeAck(pr: ToRadio) = service.serviceScope.handledLaunch { val packet = pr.packet ?: return@handledLaunch delay(2000) - callback.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) + service.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) } private fun sendConfigResponse(configId: Int) { @@ -326,8 +313,8 @@ class MockRadioTransport( user = User( id = DataPacket.nodeNumToDefaultId(numIn), - long_name = "Sim ${numIn.toString(16)}", - short_name = getInitials("Sim ${numIn.toString(16)}"), + long_name = "Sim " + numIn.toString(16), + short_name = getInitials("Sim " + numIn.toString(16)), hw_model = HardwareModel.ANDROID_SIM, ), position = @@ -366,6 +353,6 @@ class MockRadioTransport( makeNodeStatus(MY_NODE + 1), ) - packets.forEach { p -> callback.handleFromRadio(p.encode()) } + packets.forEach { p -> service.handleFromRadio(p.encode()) } } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt new file mode 100644 index 000000000..492b5782c --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.koin.core.annotation.Single +import org.meshtastic.core.repository.RadioInterfaceService + +/** Factory for creating `MockInterface` instances. */ +@Single +class MockInterfaceFactory { + fun create(rest: String, service: RadioInterfaceService): MockInterface = MockInterface(service, rest) +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceSpec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceSpec.kt new file mode 100644 index 000000000..0f77cb5dc --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceSpec.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.koin.core.annotation.Single +import org.meshtastic.core.repository.RadioInterfaceService + +/** Mock interface backend implementation. */ +@Single +class MockInterfaceSpec(private val factory: MockInterfaceFactory) : InterfaceSpec { + override fun createInterface(rest: String, service: RadioInterfaceService): MockInterface = + factory.create(rest, service) + + /** Return true if this address is still acceptable. For BLE that means, still bonded */ + override fun addressValid(rest: String): Boolean = true +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterface.kt similarity index 66% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterface.kt index c8143b1c7..27348635c 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterface.kt @@ -18,19 +18,12 @@ package org.meshtastic.core.network.radio import org.meshtastic.core.repository.RadioTransport -/** - * An intentionally inert [RadioTransport] that silently discards all operations. - * - * Used as a safe default when no valid device address is configured or when the requested transport type is - * unsupported. All method calls are no-ops — it never connects, never sends data, and never signals lifecycle events to - * the service layer. - */ -class NopRadioTransport(val address: String) : RadioTransport { +class NopInterface(val address: String) : RadioTransport { override fun handleSendToRadio(p: ByteArray) { // No-op } - override suspend fun close() { + override fun close() { // No-op } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt similarity index 74% rename from core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt index fa708d165..5d9991e34 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt @@ -14,14 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.domain.usecase.settings +package org.meshtastic.core.network.radio import org.koin.core.annotation.Single -import org.meshtastic.core.repository.UiPrefs +/** Factory for creating `NopInterface` instances. */ @Single -open class SetContrastLevelUseCase constructor(private val uiPrefs: UiPrefs) { - operator fun invoke(value: Int) { - uiPrefs.setContrastLevel(value) - } +class NopInterfaceFactory { + fun create(rest: String): NopInterface = NopInterface(rest) } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceSpec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceSpec.kt new file mode 100644 index 000000000..df77578bf --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceSpec.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import org.koin.core.annotation.Single +import org.meshtastic.core.repository.RadioInterfaceService + +/** No-op interface backend implementation. */ +@Single +class NopInterfaceSpec(private val factory: NopInterfaceFactory) : InterfaceSpec { + override fun createInterface(rest: String, service: RadioInterfaceService): NopInterface = factory.create(rest) +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt similarity index 53% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt index 8c689dbcb..ea985c020 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamInterface.kt @@ -17,11 +17,10 @@ package org.meshtastic.core.network.radio import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.network.transport.StreamFrameCodec +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportCallback /** * An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP @@ -29,48 +28,43 @@ import org.meshtastic.core.repository.RadioTransportCallback * * Delegates framing logic to [StreamFrameCodec] from `core:network`. */ -abstract class StreamTransport(protected val callback: RadioTransportCallback, protected val scope: CoroutineScope) : - RadioTransport { +abstract class StreamInterface(protected val service: RadioInterfaceService) : RadioTransport { - private val codec = - StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport") + private val codec = StreamFrameCodec(onPacketReceived = { service.handleFromRadio(it) }, logTag = "StreamInterface") - override suspend fun close() { + override fun close() { Logger.d { "Closing stream for good" } - onDeviceDisconnect(waitForStopped = true, isPermanent = true) + onDeviceDisconnect(true) } /** - * Signals the transport callback that the device has disconnected and optionally waits for the transport to stop. + * Tell MeshService our device has gone away, but wait for it to come back * - * @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside - * transport callbacks - * @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O - * errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS - * re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to - * signal a user-initiated terminal disconnect. + * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the + * manager callbacks */ - protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) { - callback.onDisconnect(isPermanent = isPermanent) + protected open fun onDeviceDisconnect(waitForStopped: Boolean) { + service.onDisconnect( + isPermanent = true, + ) // if USB device disconnects it is definitely permanently gone, not sleeping) } protected open fun connect() { - // Before connecting, send a few START1s to wake a sleeping device + // Before telling mesh service, send a few START1s to wake a sleeping device sendBytes(StreamFrameCodec.WAKE_BYTES) // Now tell clients they can (finally use the api) - callback.onConnect() + service.onConnect() } - /** Writes raw bytes to the underlying stream (serial port, TCP socket, etc.). */ abstract fun sendBytes(p: ByteArray) - /** Flushes buffered bytes to the underlying stream. No-op by default. */ + // If subclasses need to flush at the end of a packet they can implement open fun flushBytes() {} override fun handleSendToRadio(p: ByteArray) { // This method is called from a continuation and it might show up late, so check for uart being null - scope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) } + service.serviceScope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) } } /** Process a single incoming byte through the stream framing state machine. */ diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt index 9efb9150b..fe092fd7c 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt @@ -17,8 +17,6 @@ package org.meshtastic.core.network.repository import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.mqtt.ConnectionState import org.meshtastic.proto.MqttClientProxyMessage /** Interface defining the MQTT interactions used for proxying messages to and from the mesh. */ @@ -40,7 +38,4 @@ interface MQTTRepository { * @param retained Whether the message should be retained by the broker. */ fun publish(topic: String, data: ByteArray, retained: Boolean) - - /** Observable MQTT connection lifecycle state (DISCONNECTED → CONNECTING → CONNECTED → RECONNECTING). */ - val connectionState: StateFlow } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt index 47cfb6f7a..41fb652ed 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt @@ -17,47 +17,39 @@ package org.meshtastic.core.network.repository import co.touchlab.kermit.Logger +import io.github.davidepianca98.MQTTClient +import io.github.davidepianca98.mqtt.MQTTVersion +import io.github.davidepianca98.mqtt.Subscription +import io.github.davidepianca98.mqtt.packets.Qos +import io.github.davidepianca98.mqtt.packets.mqttv5.ReasonCode +import io.github.davidepianca98.mqtt.packets.mqttv5.SubscriptionOptions +import io.github.davidepianca98.socket.tls.TLSClientSettings import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.ProducerScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonDecodingException import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.safeCatching -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MqttJsonPayload import org.meshtastic.core.model.util.subscribeList import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.mqtt.ConnectionState -import org.meshtastic.mqtt.MqttClient -import org.meshtastic.mqtt.MqttEndpoint -import org.meshtastic.mqtt.MqttException -import org.meshtastic.mqtt.MqttMessage -import org.meshtastic.mqtt.QoS -import org.meshtastic.mqtt.packet.Subscription import org.meshtastic.proto.MqttClientProxyMessage -import kotlin.concurrent.Volatile @Single(binds = [MQTTRepository::class]) class MQTTRepositoryImpl( private val radioConfigRepository: RadioConfigRepository, private val nodeRepository: NodeRepository, - dispatchers: CoroutineDispatchers, + dispatchers: org.meshtastic.core.di.CoroutineDispatchers, ) : MQTTRepository { companion object { @@ -65,16 +57,12 @@ class MQTTRepositoryImpl( private const val DEFAULT_TOPIC_LEVEL = "/2/e/" private const val JSON_TOPIC_LEVEL = "/2/json/" private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org" - private const val KEEPALIVE_SECONDS = 30 private const val INITIAL_RECONNECT_DELAY_MS = 1000L private const val MAX_RECONNECT_DELAY_MS = 30_000L private const val RECONNECT_BACKOFF_MULTIPLIER = 2 } - @Volatile private var client: MqttClient? = null - - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected.Idle) - override val connectionState: StateFlow = _connectionState.asStateFlow() + private var client: MQTTClient? = null @OptIn(ExperimentalSerializationApi::class) private val json = Json { @@ -82,17 +70,23 @@ class MQTTRepositoryImpl( exceptionsWithDebugInfo = false } private val scope = CoroutineScope(dispatchers.default + SupervisorJob()) + private var clientJob: Job? = null private val publishSemaphore = Semaphore(20) + @Suppress("TooGenericExceptionCaught") override fun disconnect() { Logger.i { "MQTT Disconnecting" } - val c = client + try { + client?.disconnect(ReasonCode.SUCCESS) + } catch (e: Exception) { + Logger.w(e) { "MQTT clean disconnect failed" } + } + clientJob?.cancel() + clientJob = null client = null - _connectionState.value = ConnectionState.Disconnected.Idle - scope.launch { safeCatching { c?.close() }.onFailure { e -> Logger.w(e) { "MQTT clean disconnect failed" } } } } - @OptIn(ExperimentalSerializationApi::class) + @OptIn(ExperimentalUnsignedTypes::class) override val proxyMessageFlow: Flow = callbackFlow { val ownerId = "MeshtasticAndroidMqttProxy-${nodeRepository.myId.value ?: "unknown"}" val channelSet = radioConfigRepository.channelSetFlow.first() @@ -100,144 +94,121 @@ class MQTTRepositoryImpl( val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT } ?: DEFAULT_TOPIC_ROOT - val rawAddress = mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS - val endpoint = resolveEndpoint(rawAddress, mqttConfig?.tls_enabled == true) + val (host, port) = + (mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let { + it[0] to (it.getOrNull(1)?.toIntOrNull() ?: if (mqttConfig?.tls_enabled == true) 8883 else 1883) + } val newClient = - MqttClient(ownerId) { - keepAliveSeconds = KEEPALIVE_SECONDS - autoReconnect = true - username = mqttConfig?.username - mqttConfig?.password?.let { password(it) } - } + MQTTClient( + mqttVersion = MQTTVersion.MQTT5, + address = host, + port = port, + tls = if (mqttConfig?.tls_enabled == true) TLSClientSettings() else null, + userName = mqttConfig?.username, + password = mqttConfig?.password?.encodeToByteArray()?.toUByteArray(), + clientId = ownerId, + publishReceived = { packet -> + val topic = packet.topicName + val payload = packet.payload?.toByteArray() + Logger.d { "MQTT received message on topic $topic (size: ${payload?.size ?: 0} bytes)" } + + if (topic.contains("/json/")) { + try { + val jsonStr = payload?.decodeToString() ?: "" + // Validate JSON by parsing it + json.decodeFromString(jsonStr) + Logger.d { "MQTT parsed JSON payload successfully" } + + trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = packet.retain)) + } catch (e: kotlinx.serialization.json.JsonDecodingException) { + @OptIn(ExperimentalSerializationApi::class) + Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" } + } catch (e: kotlinx.serialization.SerializationException) { + Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } + } catch (e: IllegalArgumentException) { + Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } + } + } else { + trySend( + MqttClientProxyMessage( + topic = topic, + data_ = payload?.toByteString() ?: okio.ByteString.EMPTY, + retained = packet.retain, + ), + ) + } + }, + ) + client = newClient - val subscriptions: List = buildList { - channelSet.subscribeList.forEach { globalId -> - add( - Subscription( - "$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", - maxQos = QoS.AT_LEAST_ONCE, - noLocal = true, - ), + clientJob = + scope.launch { + var reconnectDelay = INITIAL_RECONNECT_DELAY_MS + while (true) { + try { + Logger.i { "MQTT Starting client loop for $host:$port" } + // Reset backoff on each successful connection establishment. If the broker + // disconnects cleanly after hours of operation, the next reconnect should + // start with the minimum delay rather than whatever was accumulated. + reconnectDelay = INITIAL_RECONNECT_DELAY_MS + newClient.runSuspend() + // runSuspend returned normally — broker closed connection. Retry. + Logger.w { "MQTT client loop ended normally, reconnecting in ${reconnectDelay}ms" } + } catch (e: io.github.davidepianca98.mqtt.MQTTException) { + Logger.e(e) { "MQTT Client loop error (MQTT), reconnecting in ${reconnectDelay}ms" } + } catch (e: io.github.davidepianca98.socket.IOException) { + Logger.e(e) { "MQTT Client loop error (IO), reconnecting in ${reconnectDelay}ms" } + } catch (e: kotlinx.coroutines.CancellationException) { + Logger.i { "MQTT Client loop cancelled" } + throw e + } + delay(reconnectDelay) + reconnectDelay = + (reconnectDelay * RECONNECT_BACKOFF_MULTIPLIER).coerceAtMost(MAX_RECONNECT_DELAY_MS) + } + } + + // Subscriptions: placed after runSuspend is launched and has had time to establish + // the TCP connection. KMQTT's subscribe() queues internally, but subscribing before + // the connection is ready may silently drop subscriptions depending on the version. + // A brief yield gives runSuspend() time to connect before we subscribe. + kotlinx.coroutines.yield() + + val subscriptions = mutableListOf() + channelSet.subscribeList.forEach { globalId -> + subscriptions.add( + Subscription("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), + ) + if (mqttConfig?.json_enabled == true) { + subscriptions.add( + Subscription("$rootTopic$JSON_TOPIC_LEVEL$globalId/+", SubscriptionOptions(Qos.AT_LEAST_ONCE)), ) - if (mqttConfig?.json_enabled == true) { - add( - Subscription( - "$rootTopic$JSON_TOPIC_LEVEL$globalId/+", - maxQos = QoS.AT_LEAST_ONCE, - noLocal = true, - ), - ) - } } - add(Subscription("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+", maxQos = QoS.AT_LEAST_ONCE, noLocal = true)) } + subscriptions.add(Subscription("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+", SubscriptionOptions(Qos.AT_LEAST_ONCE))) - // Collect from the SharedFlow before connecting to avoid missing retained messages - // that arrive immediately after SUBSCRIBE. - launch { newClient.messages.collect { msg -> processMessage(msg) } } - - // Forward the client's connection state to the repo-level StateFlow for UI observation. - launch { newClient.connectionState.collect { _connectionState.value = it } } - - // Retry the initial connect with exponential backoff. Once established, - // autoReconnect handles subsequent drops and re-subscribes internally. - launch { - var reconnectDelay = INITIAL_RECONNECT_DELAY_MS - while (true) { - val result = safeCatching { - Logger.i { "MQTT Connecting to $endpoint" } - newClient.connect(endpoint) - if (subscriptions.isNotEmpty()) { - Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } - newClient.subscribe(subscriptions) - } - Logger.i { "MQTT connected and subscribed" } - } - when { - result.isSuccess -> return@launch - result.exceptionOrNull() is MqttException.ConnectionRejected -> { - Logger.e(result.exceptionOrNull()) { "MQTT connection rejected (unrecoverable), stopping" } - close(result.exceptionOrNull()!!) - return@launch - } - else -> { - Logger.e(result.exceptionOrNull()) { "MQTT connect failed, retrying in ${reconnectDelay}ms" } - delay(reconnectDelay) - reconnectDelay = - (reconnectDelay * RECONNECT_BACKOFF_MULTIPLIER).coerceAtMost(MAX_RECONNECT_DELAY_MS) - } - } - } + if (subscriptions.isNotEmpty()) { + Logger.d { "MQTT subscribing to ${subscriptions.size} topics" } + newClient.subscribe(subscriptions) } awaitClose { disconnect() } } - @OptIn(ExperimentalSerializationApi::class) - private fun ProducerScope.processMessage(msg: MqttMessage) { - val topic = msg.topic - val payload = msg.payload.toByteArray() - Logger.d { "MQTT received message on topic $topic (size: ${payload.size} bytes)" } - - if (topic.contains("/json/")) { - try { - val jsonStr = payload.decodeToString() - json.decodeFromString(jsonStr) - Logger.d { "MQTT parsed JSON payload successfully" } - trySend(MqttClientProxyMessage(topic = topic, text = jsonStr, retained = msg.retain)) - } catch (e: JsonDecodingException) { - Logger.e(e) { "Failed to parse MQTT JSON: ${e.shortMessage} (path: ${e.path})" } - } catch (e: SerializationException) { - Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } - } catch (e: IllegalArgumentException) { - Logger.e(e) { "Failed to parse MQTT JSON: ${e.message}" } - } - } else { - trySend(MqttClientProxyMessage(topic = topic, data_ = payload.toByteString(), retained = msg.retain)) - } - } - + @OptIn(ExperimentalUnsignedTypes::class) override fun publish(topic: String, data: ByteArray, retained: Boolean) { - val currentClient = client - if (currentClient == null) { - Logger.w { "MQTT publish to $topic dropped: client not connected" } - return - } Logger.d { "MQTT publishing message to topic $topic (size: ${data.size} bytes, retained: $retained)" } scope.launch { publishSemaphore.withPermit { - safeCatching { - currentClient.publish( - MqttMessage(topic = topic, payload = data, qos = QoS.AT_LEAST_ONCE, retain = retained), - ) - } - .onFailure { e -> Logger.w(e) { "MQTT publish to $topic failed" } } + client?.publish( + retain = retained, + qos = Qos.AT_LEAST_ONCE, + topic = topic, + payload = data.toUByteArray(), + ) } } } } - -/** - * Resolve a user-supplied broker address into an [MqttEndpoint]. - * - * Address resolution rules: - * - If [rawAddress] already contains a URI scheme (`scheme://…`), parse it directly via [MqttEndpoint.parse] and - * respect whatever transport / port the user encoded. - * - Otherwise wrap it as a WebSocket endpoint (`ws[s]://host${WEBSOCKET_PATH}`) so the proxy works over CDNs and - * firewall-restricted networks where raw 1883/8883 may be blocked. The scheme is `wss` when [tlsEnabled] is `true`, - * `ws` otherwise. - * - * Extracted as a top-level function so [MQTTRepositoryImplTest] can exercise every branch without spinning up the full - * repository, and so `MqttManagerImpl` (in `:core:data`) can reuse the same parsing rules for the probe API. Visibility - * is `public` because Kotlin's `internal` is scoped per Gradle module. - */ -fun resolveEndpoint(rawAddress: String, tlsEnabled: Boolean): MqttEndpoint = if (rawAddress.contains("://")) { - MqttEndpoint.parse(rawAddress) -} else { - val scheme = if (tlsEnabled) "wss" else "ws" - MqttEndpoint.parse("$scheme://$rawAddress$WEBSOCKET_PATH") -} - -private const val WEBSOCKET_PATH = "/mqtt" diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt index 6c15478d9..ed7461058 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt @@ -35,14 +35,14 @@ interface ApiService { /** * Ktor-based [ApiService] implementation. * - * Uses relative paths — the base URL is set via the `DefaultRequest` plugin in the platform Koin modules. - * * Registered with `binds = []` to prevent Koin from auto-binding to [ApiService]; host modules (`app`, `desktop`) * provide their own explicit `ApiService` binding to allow platform-specific `HttpClient` engines. */ @Single(binds = []) class ApiServiceImpl(private val client: HttpClient) : ApiService { - override suspend fun getDeviceHardware(): List = client.get("resource/deviceHardware").body() + override suspend fun getDeviceHardware(): List = + client.get("https://api.meshtastic.org/resource/deviceHardware").body() - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body() + override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = + client.get("https://api.meshtastic.org/github/firmware/list").body() } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt deleted file mode 100644 index 045d3b7ec..000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.transport - -import co.touchlab.kermit.Logger -import org.meshtastic.proto.Heartbeat -import org.meshtastic.proto.ToRadio -import kotlin.concurrent.atomics.AtomicInt -import kotlin.concurrent.atomics.ExperimentalAtomicApi - -/** - * Shared heartbeat sender for Meshtastic radio transports. - * - * Constructs and sends a `ToRadio(heartbeat = Heartbeat(nonce = ...))` message to keep the firmware's idle timer from - * expiring. Each call uses a monotonically increasing nonce to prevent the firmware's per-connection duplicate-write - * filter from silently dropping it. - * - * @param sendToRadio callback to transmit the encoded heartbeat bytes to the radio - * @param afterHeartbeat optional suspend callback invoked after sending (e.g. to schedule a drain) - * @param logTag tag for log messages - */ -class HeartbeatSender( - private val sendToRadio: (ByteArray) -> Unit, - private val afterHeartbeat: (suspend () -> Unit)? = null, - private val logTag: String = "HeartbeatSender", -) { - @OptIn(ExperimentalAtomicApi::class) - private val nonce = AtomicInt(0) - - /** - * Sends a heartbeat to the radio. - * - * The firmware responds to heartbeats by queuing a `queueStatus` FromRadio packet, proving the link is alive and - * keeping the local node's lastHeard timestamp current. - */ - @OptIn(ExperimentalAtomicApi::class) - suspend fun sendHeartbeat() { - val n = nonce.fetchAndAdd(1) - Logger.v { "[$logTag] Sending ToRadio heartbeat (nonce=$n)" } - sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)).encode()) - afterHeartbeat?.invoke() - } -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt similarity index 56% rename from core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt rename to core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt index 840dc214a..d4fd0dcc1 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt @@ -22,7 +22,6 @@ import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify -import dev.mokkery.verify.VerifyMode import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy @@ -39,7 +38,7 @@ import kotlin.test.Test import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) -class BleRadioTransportTest { +class BleRadioInterfaceTest { private val testScope = TestScope() private val scanner = FakeBleScanner() @@ -56,118 +55,123 @@ class BleRadioTransportTest { } @Test - fun `connect attempts to scan and connect via start`() = runTest { + fun `connect attempts to scan and connect via init`() = runTest { val device = FakeBleDevice(address = address, name = "Test Device") scanner.emitDevice(device) - val bleTransport = - BleRadioTransport( - scope = testScope, + val bleInterface = + BleRadioInterface( + serviceScope = testScope, scanner = scanner, bluetoothRepository = bluetoothRepository, connectionFactory = connectionFactory, - callback = service, + service = service, address = address, ) - bleTransport.start() - // start() begins connect() which is async + // init starts connect() which is async // In a real test we'd verify the connection state, // but for now this confirms it works with the fakes. - assertEquals(address, bleTransport.address) + assertEquals(address, bleInterface.address) } @Test fun `address returns correct value`() { - val bleTransport = - BleRadioTransport( - scope = testScope, + val bleInterface = + BleRadioInterface( + serviceScope = testScope, scanner = scanner, bluetoothRepository = bluetoothRepository, connectionFactory = connectionFactory, - callback = service, + service = service, address = address, ) - assertEquals(address, bleTransport.address) + assertEquals(address, bleInterface.address) } /** - * After [BleReconnectPolicy.DEFAULT_FAILURE_THRESHOLD] consecutive connection failures, - * [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep - * timeout in [MeshConnectionManagerImpl]). + * After [RECONNECT_FAILURE_THRESHOLD] consecutive connection failures, [RadioInterfaceService.onDisconnect] must be + * called so the higher layers can react (e.g. start the device-sleep timeout in [MeshConnectionManagerImpl]). * - * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1 - * settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms — - * iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24 - * 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called + * Virtual-time breakdown (RECONNECT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses, + * connectAndAwait throws, backoff 5 s starts t = 6 000 ms — backoff ends t = 7 000 ms — iteration 2 settle delay + * elapses, connectAndAwait throws, backoff 10 s starts t = 17 000 ms — backoff ends t = 18 000 ms — iteration 3 + * settle delay elapses, connectAndAwait throws → onDisconnect called */ @Test - fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest { + fun `onDisconnect is called after RECONNECT_FAILURE_THRESHOLD consecutive failures`() = runTest { val device = FakeBleDevice(address = address, name = "Test Device") bluetoothRepository.bond(device) // skip BLE scan — device is already bonded // Make every connectAndAwait call throw so each iteration counts as one failure. connection.connectException = RadioNotConnectedException("simulated failure") - val bleTransport = - BleRadioTransport( - scope = this, + val bleInterface = + BleRadioInterface( + serviceScope = this, scanner = scanner, bluetoothRepository = bluetoothRepository, connectionFactory = connectionFactory, - callback = service, + service = service, address = address, ) - bleTransport.start() - // Advance through exactly 3 failure iterations (≈24 001 ms virtual time). + // Advance through exactly 3 failure iterations (≈18 001 ms virtual time). // The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended // and advanceTimeBy returns cleanly. - advanceTimeBy(24_001L) + advanceTimeBy(18_001L) verify { service.onDisconnect(any(), any()) } // Cancel the reconnect loop so runTest can complete. - bleTransport.close() + bleInterface.close() } /** - * Reconnect policy must NEVER give up on its own. The transport is only ever instantiated for the user-selected - * device, and explicit-disconnect is owned by the service layer (close()). Even after a sustained failure storm — - * well beyond the legacy [BleReconnectPolicy.DEFAULT_MAX_FAILURES] — the transport must keep retrying and must - * never call `onDisconnect(isPermanent = true)` from the give-up path. + * After [RECONNECT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and signal a permanent + * disconnect. This prevents infinite battery drain when the device is genuinely offline. * - * Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw + - * backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s - * settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance. + * Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw + + * backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s + * settle + 5+10+20+40+60+60+60+60+60 = 10 + 375 = 385s ≈ 385_000ms We use a generous 400_000ms to cover any timing + * variance. */ @Test - fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest { + fun `reconnect loop stops after RECONNECT_MAX_FAILURES with permanent disconnect`() = runTest { val device = FakeBleDevice(address = address, name = "Test Device") bluetoothRepository.bond(device) connection.connectException = RadioNotConnectedException("simulated failure") every { service.onDisconnect(any(), any()) } returns Unit - val bleTransport = - BleRadioTransport( - scope = this, + val bleInterface = + BleRadioInterface( + serviceScope = this, scanner = scanner, bluetoothRepository = bluetoothRepository, connectionFactory = connectionFactory, - callback = service, + service = service, address = address, ) - bleTransport.start() - // Run well past where the legacy policy (maxFailures = 10) would have given up. - advanceTimeBy(800_001L) + // Advance enough time for all 10 failures to occur. + advanceTimeBy(400_001L) - // Transient disconnects (isPermanent = false) are expected once the failure threshold is hit; - // the policy must NEVER signal a permanent disconnect on its own. Only explicit close() - // (verified separately by the service layer) may emit isPermanent = true. - verify(mode = VerifyMode.not) { service.onDisconnect(isPermanent = true, errorMessage = any()) } + // Should have been called with isPermanent=true at least once (the final call). + verify { service.onDisconnect(isPermanent = true, errorMessage = any()) } - bleTransport.close() + bleInterface.close() + } + + @Test + fun `computeReconnectBackoffMs returns correct backoff values`() { + assertEquals(5_000L, computeReconnectBackoffMs(0)) + assertEquals(5_000L, computeReconnectBackoffMs(1)) + assertEquals(10_000L, computeReconnectBackoffMs(2)) + assertEquals(20_000L, computeReconnectBackoffMs(3)) + assertEquals(40_000L, computeReconnectBackoffMs(4)) + assertEquals(60_000L, computeReconnectBackoffMs(5)) + assertEquals(60_000L, computeReconnectBackoffMs(10)) + assertEquals(60_000L, computeReconnectBackoffMs(100)) } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt deleted file mode 100644 index a6a7aa82c..000000000 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class BleReconnectPolicyTest { - - @Test - fun `stable disconnect resets failures and returns Continue`() { - val policy = BleReconnectPolicy() - // Simulate one prior failure - policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertEquals(1, policy.consecutiveFailures) - - // Now a stable disconnect should reset - val action = - policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false)) - assertEquals(BleReconnectPolicy.Action.Continue, action) - assertEquals(0, policy.consecutiveFailures) - } - - @Test - fun `intentional disconnect resets failures and returns Continue`() { - val policy = BleReconnectPolicy() - policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - - val action = - policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = true)) - assertEquals(BleReconnectPolicy.Action.Continue, action) - assertEquals(0, policy.consecutiveFailures) - } - - @Test - fun `unstable disconnect increments failures`() { - val policy = BleReconnectPolicy() - val action = - policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = false)) - assertEquals(1, policy.consecutiveFailures) - assertTrue(action is BleReconnectPolicy.Action.Retry) - } - - @Test - fun `failure at threshold signals transient disconnect`() { - val policy = BleReconnectPolicy(failureThreshold = 3) - // Accumulate failures up to threshold - repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertEquals(3, policy.consecutiveFailures) - assertTrue(action is BleReconnectPolicy.Action.SignalTransient) - } - - @Test - fun `failure at max gives up permanently`() { - val policy = BleReconnectPolicy(maxFailures = 3) - repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertEquals(BleReconnectPolicy.Action.GiveUp, action) - } - - @Test - fun `backoff increases with consecutive failures`() { - val policy = BleReconnectPolicy() - val backoffs = - (1..5).map { i -> - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - when (action) { - is BleReconnectPolicy.Action.Retry -> action.backoff - is BleReconnectPolicy.Action.SignalTransient -> action.backoff - else -> error("Unexpected action: $action") - } - } - // Verify backoffs are non-decreasing - for (i in 0 until backoffs.size - 1) { - assertTrue(backoffs[i] <= backoffs[i + 1], "Expected ${backoffs[i]} <= ${backoffs[i + 1]}") - } - } - - @Test - fun `custom backoff strategy is used`() { - val customBackoff = 42.seconds - val policy = BleReconnectPolicy(backoffStrategy = { customBackoff }) - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertTrue(action is BleReconnectPolicy.Action.Retry) - assertEquals(customBackoff, action.backoff) - } - - @Test - fun `maxFailures equal to failureThreshold gives up without signalling transient`() { - val policy = BleReconnectPolicy(maxFailures = 3, failureThreshold = 3) - repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - // GiveUp takes priority over SignalTransient when both thresholds are the same - assertEquals(BleReconnectPolicy.Action.GiveUp, action) - } - - @Test - fun `failure count resets after stable disconnect then re-increments`() { - val policy = BleReconnectPolicy() - // Accumulate two failures - repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } - assertEquals(2, policy.consecutiveFailures) - - // Stable disconnect resets - policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false)) - assertEquals(0, policy.consecutiveFailures) - - // New failure starts from 1 - policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertEquals(1, policy.consecutiveFailures) - } - - // region execute() loop tests - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute gives up after maxFailures and calls onPermanentDisconnect`() = runTest { - val policy = - BleReconnectPolicy(maxFailures = 3, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) - var permanentError: Throwable? = null - var permanentCalled = false - var transientCalled = false - - policy.execute( - attempt = { BleReconnectPolicy.Outcome.Failed(RuntimeException("connection failed")) }, - onTransientDisconnect = { transientCalled = true }, - onPermanentDisconnect = { error -> - permanentCalled = true - permanentError = error - }, - ) - - assertTrue(permanentCalled, "onPermanentDisconnect should have been called") - assertNotNull(permanentError, "error should be passed to onPermanentDisconnect") - assertEquals("connection failed", permanentError?.message) - assertEquals(3, policy.consecutiveFailures) - // failureThreshold defaults to 3, same as maxFailures here, so GiveUp takes priority - assertTrue(!transientCalled, "onTransientDisconnect should not be called when GiveUp fires first") - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute calls onTransientDisconnect at threshold then continues retrying`() = runTest { - var attemptCount = 0 - val policy = - BleReconnectPolicy( - maxFailures = 5, - failureThreshold = 2, - settleDelay = 1.milliseconds, - backoffStrategy = { 1.milliseconds }, - ) - var transientCount = 0 - - policy.execute( - attempt = { - attemptCount++ - BleReconnectPolicy.Outcome.Failed(RuntimeException("fail #$attemptCount")) - }, - onTransientDisconnect = { transientCount++ }, - onPermanentDisconnect = {}, - ) - - assertEquals(5, attemptCount, "should attempt exactly maxFailures times") - // Transient is signalled for failures 2, 3, 4 (at or above threshold, below maxFailures) - assertEquals(3, transientCount, "should signal transient for each failure at or above threshold") - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute continues immediately after stable disconnect`() = runTest { - var attemptCount = 0 - val policy = - BleReconnectPolicy(maxFailures = 5, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) - - policy.execute( - attempt = { - attemptCount++ - if (attemptCount <= 2) { - // First two attempts connect briefly and disconnect stably - BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false) - } else { - // Then fail until maxFailures - BleReconnectPolicy.Outcome.Failed(RuntimeException("fail")) - } - }, - onTransientDisconnect = {}, - onPermanentDisconnect = {}, - ) - - // 2 stable disconnects + 5 failures (counter resets after each stable, so needs 5 more to hit max) - assertEquals(7, attemptCount) - assertEquals(5, policy.consecutiveFailures) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute passes null error for unstable disconnect at threshold`() = runTest { - val policy = - BleReconnectPolicy( - maxFailures = 5, - failureThreshold = 2, - settleDelay = 1.milliseconds, - backoffStrategy = { 1.milliseconds }, - ) - val transientErrors = mutableListOf() - var attemptCount = 0 - - policy.execute( - attempt = { - attemptCount++ - // Use unstable disconnects (not Failed) so lastError is null - BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = false) - }, - onTransientDisconnect = { error -> transientErrors.add(error) }, - onPermanentDisconnect = {}, - ) - - // Disconnected outcomes don't have errors, so all transient callbacks get null - assertTrue(transientErrors.all { it == null }, "Disconnected outcomes should pass null error") - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute stops when coroutine is cancelled`() = runTest { - var attemptCount = 0 - val policy = - BleReconnectPolicy(maxFailures = 100, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) - - val job = - backgroundScope.launch { - policy.execute( - attempt = { - attemptCount++ - // Always succeed stably — loop should run until cancelled - BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false) - }, - onTransientDisconnect = {}, - onPermanentDisconnect = {}, - ) - } - - // Let a few iterations run, then cancel - advanceTimeBy(50) - job.cancel() - advanceUntilIdle() - - // Should have made some attempts but not reached maxFailures - assertTrue(attemptCount > 0, "should have attempted at least once") - assertTrue(attemptCount < 100, "should not have exhausted all failures — was cancelled") - } - - // endregion -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt index f3514c752..007b82b45 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt @@ -19,52 +19,51 @@ package org.meshtastic.core.network.radio import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds /** - * Tests the exponential backoff schedule used by [BleRadioTransport] when consecutive connection attempts fail. The + * Tests the exponential backoff schedule used by [BleRadioInterface] when consecutive connection attempts fail. The * schedule is: failure #1 → 5 s failure #2 → 10 s failure #3 → 20 s failure #4 → 40 s failure #5+ → 60 s (capped) */ class ReconnectBackoffTest { @Test fun `zero failures yields base delay`() { - assertEquals(5.seconds, computeReconnectBackoff(0)) + assertEquals(5_000L, computeReconnectBackoffMs(0)) } @Test fun `first failure yields 5s`() { - assertEquals(5.seconds, computeReconnectBackoff(1)) + assertEquals(5_000L, computeReconnectBackoffMs(1)) } @Test fun `second failure yields 10s`() { - assertEquals(10.seconds, computeReconnectBackoff(2)) + assertEquals(10_000L, computeReconnectBackoffMs(2)) } @Test fun `third failure yields 20s`() { - assertEquals(20.seconds, computeReconnectBackoff(3)) + assertEquals(20_000L, computeReconnectBackoffMs(3)) } @Test fun `fourth failure yields 40s`() { - assertEquals(40.seconds, computeReconnectBackoff(4)) + assertEquals(40_000L, computeReconnectBackoffMs(4)) } @Test fun `fifth failure is capped at 60s`() { - assertEquals(60.seconds, computeReconnectBackoff(5)) + assertEquals(60_000L, computeReconnectBackoffMs(5)) } @Test fun `large failure count stays capped at 60s`() { - assertEquals(60.seconds, computeReconnectBackoff(100)) + assertEquals(60_000L, computeReconnectBackoffMs(100)) } @Test fun `backoff is strictly increasing up to the cap`() { - val values = (1..5).map { computeReconnectBackoff(it) } + val values = (1..5).map { computeReconnectBackoffMs(it) } for (i in 0 until values.size - 1) { assertTrue( values[i] < values[i + 1], diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt similarity index 75% rename from core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt rename to core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt index 6faa69217..4c4e9b4be 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamInterfaceTest.kt @@ -17,6 +17,8 @@ package org.meshtastic.core.network.radio import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every import dev.mokkery.mock import dev.mokkery.verify import io.kotest.property.Arb @@ -27,16 +29,17 @@ import io.kotest.property.checkAll import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.meshtastic.core.network.transport.StreamFrameCodec -import org.meshtastic.core.repository.RadioTransportCallback +import org.meshtastic.core.repository.RadioInterfaceService +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertTrue -class StreamTransportTest { +class StreamInterfaceTest { - private val callback: RadioTransportCallback = mock(MockMode.autofill) - private lateinit var fakeStream: FakeStreamTransport + private val radioService: RadioInterfaceService = mock(MockMode.autofill) + private lateinit var fakeStream: FakeStreamInterface - class FakeStreamTransport(callback: RadioTransportCallback, scope: TestScope) : StreamTransport(callback, scope) { + class FakeStreamInterface(service: RadioInterfaceService) : StreamInterface(service) { val sentBytes = mutableListOf() override fun sendBytes(p: ByteArray) { @@ -56,18 +59,21 @@ class StreamTransportTest { public override fun connect() = super.connect() } - private val testScope = TestScope() + @BeforeTest + fun setUp() { + every { radioService.serviceScope } returns TestScope() + } @Test fun `handleSendToRadio property test`() = runTest { - fakeStream = FakeStreamTransport(callback, testScope) + fakeStream = FakeStreamInterface(radioService) checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) } } @Test fun `readChar property test`() = runTest { - fakeStream = FakeStreamTransport(callback, testScope) + fakeStream = FakeStreamInterface(radioService) checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data -> data.forEach { fakeStream.feed(it) } @@ -77,11 +83,11 @@ class StreamTransportTest { @Test fun `connect sends wake bytes`() { - fakeStream = FakeStreamTransport(callback, testScope) + fakeStream = FakeStreamInterface(radioService) fakeStream.connect() assertTrue(fakeStream.sentBytes.isNotEmpty()) assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES)) - verify { callback.onConnect() } + verify { radioService.onConnect() } } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt index 26b83a420..73e096da9 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt @@ -18,82 +18,25 @@ package org.meshtastic.core.network.repository import kotlinx.serialization.json.Json import org.meshtastic.core.model.MqttJsonPayload -import org.meshtastic.mqtt.MqttEndpoint import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertIs import kotlin.test.assertTrue class MQTTRepositoryImplTest { - // region resolveEndpoint — every behavioral branch of address parsing. - @Test - fun `bare host without scheme is wrapped as ws WebSocket on the standard port`() { - val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = false) + fun `test address parsing logic`() { + val address1 = "mqtt.example.com:1883" + val (host1, port1) = address1.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) } + assertEquals("mqtt.example.com", host1) + assertEquals(1883, port1) - val ws = assertIs(endpoint) - assertEquals("ws://broker.example.com/mqtt", ws.url) + val address2 = "mqtt.example.com" + val (host2, port2) = address2.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 1883) } + assertEquals("mqtt.example.com", host2) + assertEquals(1883, port2) } - @Test - fun `bare host with TLS enabled is upgraded to wss`() { - val endpoint = resolveEndpoint(rawAddress = "broker.example.com", tlsEnabled = true) - - val ws = assertIs(endpoint) - assertEquals("wss://broker.example.com/mqtt", ws.url) - } - - @Test - fun `host with explicit port is preserved when wrapped`() { - val endpoint = resolveEndpoint(rawAddress = "broker.example.com:9001", tlsEnabled = false) - - val ws = assertIs(endpoint) - assertEquals("ws://broker.example.com:9001/mqtt", ws.url) - } - - @Test - fun `address with ws scheme is parsed as-is and tls flag is ignored`() { - // tlsEnabled is intentionally true here — when the user supplies a full URL we - // must honor whatever scheme they provided, not silently upgrade it. - val endpoint = resolveEndpoint(rawAddress = "ws://broker.example.com:8080/custom-path", tlsEnabled = true) - - val ws = assertIs(endpoint) - assertEquals("ws://broker.example.com:8080/custom-path", ws.url) - } - - @Test - fun `address with wss scheme is parsed as-is`() { - val endpoint = resolveEndpoint(rawAddress = "wss://broker.example.com/secure-mqtt", tlsEnabled = false) - - val ws = assertIs(endpoint) - assertEquals("wss://broker.example.com/secure-mqtt", ws.url) - } - - @Test - fun `address with mqtt tcp scheme is parsed as Tcp endpoint`() { - val endpoint = resolveEndpoint(rawAddress = "mqtt://broker.example.com:1883", tlsEnabled = false) - - val tcp = assertIs(endpoint) - assertEquals("broker.example.com", tcp.host) - assertEquals(1883, tcp.port) - assertEquals(false, tcp.tls) - } - - @Test - fun `address with mqtts tcp scheme is parsed as Tcp endpoint with tls true`() { - val endpoint = resolveEndpoint(rawAddress = "mqtts://broker.example.com:8883", tlsEnabled = false) - - val tcp = assertIs(endpoint) - assertEquals("broker.example.com", tcp.host) - assertEquals(8883, tcp.port) - assertEquals(true, tcp.tls) - } - - // endregion - - // region MqttJsonPayload — keep the existing JSON contract tests. - @Test fun `test json payload parsing`() { val jsonStr = @@ -129,6 +72,4 @@ class MQTTRepositoryImplTest { assertTrue(jsonStr.contains("\"from\":12345678")) assertTrue(jsonStr.contains("\"payload\":\"Hello World\"")) } - - // endregion } diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt new file mode 100644 index 000000000..adab96d4d --- /dev/null +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TCPInterface.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.network.radio + +import co.touchlab.kermit.Logger +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.network.transport.StreamFrameCodec +import org.meshtastic.core.network.transport.TcpTransport +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioTransport + +/** + * Android TCP radio interface — thin adapter over the shared [TcpTransport] from `core:network`. + * + * Manages the mapping between the Android-specific [StreamInterface]/[RadioTransport] contract and the shared transport + * layer. + */ +open class TCPInterface( + service: RadioInterfaceService, + private val dispatchers: CoroutineDispatchers, + private val address: String, +) : StreamInterface(service) { + + companion object { + const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT + } + + private val transport = + TcpTransport( + dispatchers = dispatchers, + scope = service.serviceScope, + listener = + object : TcpTransport.Listener { + override fun onConnected() { + super@TCPInterface.connect() + } + + override fun onDisconnected() { + // Transport already performed teardown; only propagate lifecycle to StreamInterface. + super@TCPInterface.onDeviceDisconnect(false) + } + + override fun onPacketReceived(bytes: ByteArray) { + service.handleFromRadio(bytes) + } + }, + logTag = "TCPInterface[$address]", + ) + + init { + connect() + } + + override fun sendBytes(p: ByteArray) { + // Direct byte sending is handled by the transport; this is used by StreamInterface for serial compat + Logger.d { "[$address] TCPInterface.sendBytes delegated to transport" } + } + + override fun onDeviceDisconnect(waitForStopped: Boolean) { + transport.stop() + super.onDeviceDisconnect(waitForStopped) + } + + override fun connect() { + transport.start(address) + } + + override fun keepAlive() { + Logger.d { "[$address] TCP keepAlive" } + service.serviceScope.handledLaunch { transport.sendHeartbeat() } + } + + override fun handleSendToRadio(p: ByteArray) { + service.serviceScope.handledLaunch { transport.sendPacket(p) } + } +} diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt deleted file mode 100644 index 202d8de57..000000000 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.network.transport.StreamFrameCodec -import org.meshtastic.core.network.transport.TcpTransport -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportCallback -import kotlin.concurrent.Volatile - -/** - * TCP radio transport — thin adapter over the shared [TcpTransport] from `core:network`. - * - * Implements [RadioTransport] directly via composition over [TcpTransport], delegating send/receive to the transport - * and calling [RadioTransportCallback] for lifecycle events. This avoids the previous inheritance from - * [StreamTransport] which created a dead [StreamFrameCodec] and required overriding `sendBytes` as a no-op. - */ -open class TcpRadioTransport( - private val callback: RadioTransportCallback, - private val scope: CoroutineScope, - private val dispatchers: CoroutineDispatchers, - private val address: String, -) : RadioTransport { - - companion object { - const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT - } - - /** Guards against a double [RadioTransportCallback.onDisconnect] when [close] triggers [TcpTransport.stop]. */ - @Volatile private var closing = false - - private val transport = - TcpTransport( - dispatchers = dispatchers, - scope = scope, - listener = - object : TcpTransport.Listener { - override fun onConnected() { - callback.onConnect() - } - - override fun onDisconnected() { - if (closing) return // close() will fire the permanent disconnect itself - // TCP disconnects are transient (not permanent) — the transport will auto-reconnect. - callback.onDisconnect(isPermanent = false) - } - - override fun onPacketReceived(bytes: ByteArray) { - callback.handleFromRadio(bytes) - } - }, - logTag = "TcpRadioTransport[$address]", - ) - - override fun start() { - transport.start(address) - } - - override suspend fun close() { - Logger.d { "[$address] Closing TCP transport" } - closing = true - transport.stop() - // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the - // service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting - // it from close() caused a double-disconnect and prevented the auto-reconnect loop from - // owning its own lifecycle. The `closing` guard above suppresses the listener's transient - // disconnect during teardown. - } - - override fun keepAlive() { - Logger.d { "[$address] TCP keepAlive" } - scope.handledLaunch { transport.sendHeartbeat() } - } - - override fun handleSendToRadio(p: ByteArray) { - scope.handledLaunch { transport.sendPacket(p) } - } -} diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt index 172423470..dcc0a402f 100644 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt +++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.proto.Heartbeat import org.meshtastic.proto.ToRadio import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -33,14 +34,13 @@ import java.net.InetAddress import java.net.Socket import java.net.SocketTimeoutException import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger /** * Shared JVM TCP transport for Meshtastic radios. * * Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff) and uses [StreamFrameCodec] for the - * START1/START2 stream framing protocol. [sendHeartbeat] sends a heartbeat with a monotonically-increasing nonce so the - * firmware's per-connection duplicate-write filter does not silently drop it. + * START1/START2 stream framing protocol. Heartbeat scheduling is owned by [SharedRadioInterfaceService]; this class + * only exposes [sendHeartbeat] for external callers. * * Used by Android and Desktop via the shared `SharedRadioInterfaceService`. */ @@ -65,10 +65,6 @@ class TcpTransport( } companion object { - /** - * Maximum reconnect retries. Set to [Int.MAX_VALUE] to retry indefinitely — the caller ([TcpTransport.stop]) - * owns the cancellation lifecycle. - */ const val MAX_RECONNECT_RETRIES = Int.MAX_VALUE const val MIN_BACKOFF_MILLIS = 1_000L const val MAX_BACKOFF_MILLIS = 5 * 60 * 1_000L @@ -88,28 +84,18 @@ class TcpTransport( ) // TCP socket state - @Volatile private var socket: Socket? = null - - @Volatile private var outStream: OutputStream? = null - - @Volatile private var connectionJob: Job? = null - - @Volatile private var currentAddress: String? = null + private var socket: Socket? = null + private var outStream: OutputStream? = null + private var connectionJob: Job? = null + private var currentAddress: String? = null // Metrics - @Volatile private var connectionStartTime: Long = 0 - - @Volatile private var packetsReceived: Int = 0 - - @Volatile private var packetsSent: Int = 0 - - @Volatile private var bytesReceived: Long = 0 - - @Volatile private var bytesSent: Long = 0 - - @Volatile private var timeoutEvents: Int = 0 - - private val heartbeatNonce = AtomicInteger(0) + private var connectionStartTime: Long = 0 + private var packetsReceived: Int = 0 + private var packetsSent: Int = 0 + private var bytesReceived: Long = 0 + private var bytesSent: Long = 0 + private var timeoutEvents: Int = 0 /** Whether the transport is currently connected. */ val isConnected: Boolean @@ -148,10 +134,9 @@ class TcpTransport( bytesSent += payload.size } - /** Send a heartbeat packet with a monotonically-increasing nonce to keep the connection alive. */ + /** Send a heartbeat packet to keep the connection alive. */ suspend fun sendHeartbeat() { - val nonce = heartbeatNonce.getAndIncrement() - val heartbeat = ToRadio(heartbeat = org.meshtastic.proto.Heartbeat(nonce = nonce)) + val heartbeat = ToRadio(heartbeat = Heartbeat()) sendPacket(heartbeat.encode()) } diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt index 45ba70eb7..a77331267 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt @@ -19,19 +19,18 @@ package org.meshtastic.core.network import co.touchlab.kermit.Logger import com.fazecast.jSerialComm.SerialPort import com.fazecast.jSerialComm.SerialPortTimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.network.radio.StreamTransport -import org.meshtastic.core.network.transport.HeartbeatSender -import org.meshtastic.core.repository.RadioTransportCallback +import org.meshtastic.core.network.radio.StreamInterface +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.proto.Heartbeat +import org.meshtastic.proto.ToRadio import java.io.File /** - * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamTransport] for START1/START2 packet + * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamInterface] for START1/START2 packet * framing. * * Use the [open] factory method instead of the constructor directly to ensure the serial port is opened and the read @@ -41,15 +40,12 @@ class SerialTransport private constructor( private val portName: String, private val baudRate: Int = DEFAULT_BAUD_RATE, - callback: RadioTransportCallback, - scope: CoroutineScope, + service: RadioInterfaceService, private val dispatchers: CoroutineDispatchers, -) : StreamTransport(callback, scope) { +) : StreamInterface(service) { private var serialPort: SerialPort? = null private var readJob: Job? = null - private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$portName]") - /** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */ private fun startConnection(): Boolean { return try { @@ -61,7 +57,7 @@ private constructor( port.setDTR() port.setRTS() Logger.i { "[$portName] Serial port opened (baud=$baudRate)" } - super.connect() // Sends WAKE_BYTES and signals callback.onConnect() + super.connect() // Sends WAKE_BYTES and signals service.onConnect() startReadLoop(port) true } else { @@ -78,7 +74,7 @@ private constructor( private fun startReadLoop(port: SerialPort) { Logger.d { "[$portName] Starting serial read loop" } readJob = - scope.launch(dispatchers.io) { + service.serviceScope.launch(dispatchers.io) { val input = port.inputStream val buffer = ByteArray(READ_BUFFER_SIZE) try { @@ -95,7 +91,7 @@ private constructor( } } catch (_: SerialPortTimeoutException) { // Expected timeout when no data is available - } catch (e: CancellationException) { + } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { if (isActive) { @@ -106,7 +102,7 @@ private constructor( reading = false } } - } catch (e: CancellationException) { + } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { if (isActive) { @@ -129,10 +125,7 @@ private constructor( // Ignore errors during port close } if (isActive) { - // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as - // transient — the user did not explicitly disconnect, and the port may come - // back when the device is replugged or the OS re-enumerates it. - onDeviceDisconnect(waitForStopped = true, isPermanent = false) + onDeviceDisconnect(true) } } } @@ -147,9 +140,11 @@ private constructor( } override fun keepAlive() { - // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the - // serial link is alive. - scope.launch { heartbeatSender.sendHeartbeat() } + // Send a ToRadio heartbeat so the firmware resets its idle timer and responds with + // a FromRadio queueStatus — proving the serial link is alive. Without this, the + // serial transport has no way to detect a silently dead device. + Logger.d { "[$portName] Serial keepAlive — sending heartbeat" } + handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode()) } private fun closePortResources() { @@ -157,7 +152,7 @@ private constructor( serialPort = null } - override suspend fun close() { + override fun close() { Logger.d { "[$portName] Closing serial transport" } readJob?.cancel() readJob = null @@ -172,23 +167,20 @@ private constructor( private const val READ_TIMEOUT_MS = 100 /** - * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient - * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as - * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the - * user grants permission); only an explicit close should signal a permanent disconnect. + * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent + * disconnect to the [service] and returns the (non-connected) instance. */ fun open( portName: String, baudRate: Int = DEFAULT_BAUD_RATE, - callback: RadioTransportCallback, - scope: CoroutineScope, + service: RadioInterfaceService, dispatchers: CoroutineDispatchers, ): SerialTransport { - val transport = SerialTransport(portName, baudRate, callback, scope, dispatchers) + val transport = SerialTransport(portName, baudRate, service, dispatchers) if (!transport.startConnection()) { val errorMessage = diagnoseOpenFailure(portName) Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" } - callback.onDisconnect(isPermanent = false, errorMessage = errorMessage) + service.onDisconnect(isPermanent = true, errorMessage = errorMessage) } return transport } diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt index 34b9e49a3..1b46232bf 100644 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt +++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscovery.kt @@ -17,12 +17,12 @@ package org.meshtastic.core.network.repository import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOn import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers import java.io.IOException import java.net.InetAddress import java.net.NetworkInterface @@ -31,7 +31,7 @@ import javax.jmdns.ServiceEvent import javax.jmdns.ServiceListener @Single -class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : ServiceDiscovery { +class JvmServiceDiscovery : ServiceDiscovery { @Suppress("TooGenericExceptionCaught") override val resolvedServices: Flow> = callbackFlow { @@ -98,7 +98,7 @@ class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : Servi } } } - .flowOn(dispatchers.io) + .flowOn(Dispatchers.IO) companion object { /** diff --git a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt index 5884daaaf..e03076f39 100644 --- a/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt +++ b/core/network/src/jvmTest/kotlin/org/meshtastic/core/network/repository/JvmServiceDiscoveryTest.kt @@ -17,23 +17,16 @@ package org.meshtastic.core.network.repository import app.cash.turbine.test -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import org.meshtastic.core.di.CoroutineDispatchers import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertTrue class JvmServiceDiscoveryTest { - private val testDispatchers = - UnconfinedTestDispatcher().let { dispatcher -> - CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) - } - @Test fun `resolvedServices emits initial empty list immediately`() = runTest { - val discovery = JvmServiceDiscovery(testDispatchers) + val discovery = JvmServiceDiscovery() discovery.resolvedServices.test { val first = awaitItem() assertNotNull(first, "First emission should not be null") diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts index 96bba529e..eba3604d7 100644 --- a/core/prefs/build.gradle.kts +++ b/core/prefs/build.gradle.kts @@ -24,7 +24,7 @@ kotlin { android { namespace = "org.meshtastic.core.prefs" androidResources.enable = false - withHostTest {} + withHostTest { isIncludeAndroidResources = true } } sourceSets { diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt b/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt similarity index 84% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt rename to core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt index b38c822fe..3ba095531 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt +++ b/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt @@ -22,22 +22,18 @@ import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import okio.FileSystem -import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FilterPrefs +import java.io.File import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid -@OptIn(ExperimentalUuidApi::class) class FilterPrefsTest { - private lateinit var tmpDir: Path + private lateinit var tmpFolder: File private lateinit var dataStore: DataStore private lateinit var filterPrefs: FilterPrefs @@ -48,12 +44,15 @@ class FilterPrefsTest { @BeforeTest fun setup() { - tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "filterPrefsTest-${Uuid.random()}" - FileSystem.SYSTEM.createDirectories(tmpDir) + tmpFolder = + File.createTempFile("filterPrefsTest", null).apply { + delete() + mkdirs() + } dataStore = - PreferenceDataStoreFactory.createWithPath( + PreferenceDataStoreFactory.create( scope = testScope, - produceFile = { tmpDir / "test.preferences_pb" }, + produceFile = { File(tmpFolder, "test.preferences_pb").also { it.createNewFile() } }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) filterPrefs = FilterPrefsImpl(dataStore, dispatchers) @@ -61,7 +60,7 @@ class FilterPrefsTest { @AfterTest fun tearDown() { - FileSystem.SYSTEM.deleteRecursively(tmpDir) + tmpFolder.deleteRecursively() } @Test fun `filterEnabled defaults to false`() = testScope.runTest { assertFalse(filterPrefs.filterEnabled.value) } diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt b/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt similarity index 85% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt rename to core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt index a5792e800..51571786c 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt +++ b/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt @@ -22,21 +22,17 @@ import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import okio.FileSystem -import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.NotificationPrefs +import java.io.File import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid -@OptIn(ExperimentalUuidApi::class) class NotificationPrefsTest { - private lateinit var tmpDir: Path + private lateinit var tmpFolder: File private lateinit var dataStore: DataStore private lateinit var notificationPrefs: NotificationPrefs @@ -47,12 +43,15 @@ class NotificationPrefsTest { @BeforeTest fun setup() { - tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "notificationPrefsTest-${Uuid.random()}" - FileSystem.SYSTEM.createDirectories(tmpDir) + tmpFolder = + File.createTempFile("notificationPrefsTest", null).apply { + delete() + mkdirs() + } dataStore = - PreferenceDataStoreFactory.createWithPath( + PreferenceDataStoreFactory.create( scope = testScope, - produceFile = { tmpDir / "test.preferences_pb" }, + produceFile = { File(tmpFolder, "test.preferences_pb").also { it.createNewFile() } }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) notificationPrefs = NotificationPrefsImpl(dataStore, dispatchers) @@ -60,7 +59,7 @@ class NotificationPrefsTest { @AfterTest fun tearDown() { - FileSystem.SYSTEM.deleteRecursively(tmpDir) + tmpFolder.deleteRecursively() } @Test diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt b/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt similarity index 79% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt rename to core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt index 2ad0ad21c..caa60fe70 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt +++ b/core/prefs/src/androidHostTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt @@ -22,21 +22,17 @@ import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import okio.FileSystem -import okio.Path +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.TakPrefs -import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid -@OptIn(ExperimentalUuidApi::class) class TakPrefsTest { - private lateinit var tmpDir: Path + @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() private lateinit var dataStore: DataStore private lateinit var takPrefs: TakPrefs @@ -47,22 +43,15 @@ class TakPrefsTest { @BeforeTest fun setup() { - tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "takPrefsTest-${Uuid.random()}" - FileSystem.SYSTEM.createDirectories(tmpDir) dataStore = - PreferenceDataStoreFactory.createWithPath( + PreferenceDataStoreFactory.create( scope = testScope, - produceFile = { tmpDir / "test.preferences_pb" }, + produceFile = { tmpFolder.newFile("test.preferences_pb") }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) takPrefs = TakPrefsImpl(dataStore, dispatchers) } - @AfterTest - fun tearDown() { - FileSystem.SYSTEM.deleteRecursively(tmpDir) - } - @Test fun `isTakServerEnabled defaults to false`() = testScope.runTest { assertFalse(takPrefs.isTakServerEnabled.value) } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt index d6c85d266..5395ce723 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/FlowCache.kt @@ -19,31 +19,19 @@ package org.meshtastic.core.prefs import kotlinx.atomicfu.AtomicRef import kotlinx.collections.immutable.PersistentMap -/** - * Look up [key] in [cache]; if absent, construct a value via [build] and insert it atomically. - * - * [build] is wrapped in a [Lazy] before being published to [cache], so concurrent first-access of the same key never - * invokes [build] more than once — only the winner of the CAS has its [Lazy] evaluated, and all readers share that same - * result. This matters when [build] eagerly launches a coroutine (e.g. `Flow.stateIn(scope, Eagerly, …)`): the naive - * approach would leak the losing coroutine into a never-cancelled scope. - */ -@Suppress("ReturnCount") -internal inline fun cachedFlow( - cache: AtomicRef>>, - key: K, - crossinline build: () -> V, -): V { - cache.value[key]?.let { - return it.value - } - val newLazy = lazy(LazyThreadSafetyMode.SYNCHRONIZED) { build() } - while (true) { - val current = cache.value - current[key]?.let { - return it.value - } - if (cache.compareAndSet(current, current.put(key, newLazy))) { - return newLazy.value +internal inline fun cachedFlow(cache: AtomicRef>, key: K, build: () -> V): V { + var resolved = cache.value[key] + if (resolved == null) { + val newValue = build() + while (resolved == null) { + val current = cache.value + val currentValue = current[key] + if (currentValue != null) { + resolved = currentValue + } else if (cache.compareAndSet(current, current.put(key, newValue))) { + resolved = newValue + } } } + return checkNotNull(resolved) } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt index c43d4b2bb..763c81120 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt @@ -42,7 +42,7 @@ class MapConsentPrefsImpl( ) : MapConsentPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - private val consentFlows = atomic(persistentMapOf>>()) + private val consentFlows = atomic(persistentMapOf>()) override fun shouldReportLocation(nodeNum: Int?): StateFlow = cachedFlow(consentFlows, nodeNum) { val key = booleanPreferencesKey(nodeNum.toString()) diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt index f3ddaad4e..ad982e6a6 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt @@ -18,6 +18,7 @@ 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 @@ -32,7 +33,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.normalizeAddress import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.cachedFlow import org.meshtastic.core.repository.MeshPrefs @@ -44,7 +44,8 @@ class MeshPrefsImpl( ) : MeshPrefs { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - private val storeForwardFlows = atomic(persistentMapOf>>()) + private val locationFlows = atomic(persistentMapOf>()) + private val storeForwardFlows = atomic(persistentMapOf>()) override val deviceAddress: StateFlow = dataStore.data @@ -63,6 +64,15 @@ class MeshPrefsImpl( } } + override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = cachedFlow(locationFlows, nodeNum) { + val key = booleanPreferencesKey(provideLocationKey(nodeNum)) + dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) + } + + override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) { + scope.launch { dataStore.edit { prefs -> prefs[booleanPreferencesKey(provideLocationKey(nodeNum))] = provide } } + } + override fun getStoreForwardLastRequest(address: String?): StateFlow = cachedFlow(storeForwardFlows, address) { val key = intPreferencesKey(storeForwardKey(address)) dataStore.data.map { it[key] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0) @@ -81,8 +91,19 @@ class MeshPrefsImpl( } } + private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum" + private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}" + private fun normalizeAddress(address: String?): String { + val raw = address?.trim()?.takeIf { it.isNotEmpty() } + return when { + raw == null -> "DEFAULT" + raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT" + else -> raw.uppercase().replace(":", "") + } + } + companion object { val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address") } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index c0b88d385..33f688389 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -46,7 +46,7 @@ class UiPrefsImpl( private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) // Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref - private val provideNodeLocationFlows = atomic(persistentMapOf>>()) + private val provideNodeLocationFlows = atomic(persistentMapOf>()) override val appIntroCompleted: StateFlow = dataStore.data.map { it[KEY_APP_INTRO_COMPLETED] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) @@ -62,13 +62,6 @@ class UiPrefsImpl( scope.launch { dataStore.edit { it[KEY_THEME] = value } } } - override val contrastLevel: StateFlow = - dataStore.data.map { it[KEY_CONTRAST_LEVEL] ?: 0 }.stateIn(scope, SharingStarted.Lazily, 0) - - override fun setContrastLevel(value: Int) { - scope.launch { dataStore.edit { it[KEY_CONTRAST_LEVEL] = value } } - } - override val locale: StateFlow = dataStore.data.map { it[KEY_LOCALE] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "") @@ -159,7 +152,6 @@ class UiPrefsImpl( val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed") val KEY_THEME = intPreferencesKey("theme") - val KEY_CONTRAST_LEVEL = intPreferencesKey("contrast-level") val KEY_LOCALE = stringPreferencesKey("locale") val KEY_NODE_SORT = intPreferencesKey("node-sort-option") val KEY_INCLUDE_UNKNOWN = booleanPreferencesKey("include-unknown") diff --git a/core/proto/consumer-rules.pro b/core/proto/consumer-rules.pro new file mode 100644 index 000000000..e9dc3751a --- /dev/null +++ b/core/proto/consumer-rules.pro @@ -0,0 +1,43 @@ +# Core proto classes required for packet handling and serialization +# FromRadio and related message types (primary packet container) +-keep class org.meshtastic.proto.FromRadio +-keep class org.meshtastic.proto.Data +-keep class org.meshtastic.proto.MeshPacket +-keep class org.meshtastic.proto.LogRecord + +# Message type payloads (handled in packet routing) +-keep class org.meshtastic.proto.AdminMessage +-keep class org.meshtastic.proto.StoreAndForward +-keep class org.meshtastic.proto.StoreForwardPlusPlus +-keep class org.meshtastic.proto.Routing + +# User and Node information +-keep class org.meshtastic.proto.User +-keep class org.meshtastic.proto.NeighborInfo +-keep class org.meshtastic.proto.Neighbor + +# Location and environment data +-keep class org.meshtastic.proto.Position +-keep class org.meshtastic.proto.Waypoint +-keep class org.meshtastic.proto.StatusMessage + +# Telemetry data types +-keep class org.meshtastic.proto.Telemetry +-keep class org.meshtastic.proto.DeviceMetrics +-keep class org.meshtastic.proto.EnvironmentMetrics +-keep class org.meshtastic.proto.AirQualityMetrics +-keep class org.meshtastic.proto.PowerMetrics +-keep class org.meshtastic.proto.LocalStats +-keep class org.meshtastic.proto.HostMetrics + +# Other data +-keep class org.meshtastic.proto.Paxcount +-keep class org.meshtastic.proto.DeviceMetadata + +# Configuration classes +-keep class org.meshtastic.proto.ChannelSet +-keep class org.meshtastic.proto.LocalConfig +-keep class org.meshtastic.proto.Config +-keep class org.meshtastic.proto.ModuleConfig +-keep class org.meshtastic.proto.Channel +-keep class org.meshtastic.proto.ClientNotification diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index 4d5b500df..a4c649bd3 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c +Subproject commit a4c649bd3e877dab9011d9e32dc778640ec22852 diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts index ce7ac4abc..9eb277575 100644 --- a/core/repository/build.gradle.kts +++ b/core/repository/build.gradle.kts @@ -22,10 +22,7 @@ plugins { kotlin { @Suppress("UnstableApiUsage") - android { - androidResources.enable = false - withHostTest {} - } + android { androidResources.enable = false } sourceSets { commonMain.dependencies { diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index d7400332d..f5203e3c1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -80,10 +80,6 @@ interface UiPrefs { fun setTheme(value: Int) - val contrastLevel: StateFlow - - fun setContrastLevel(value: Int) - val locale: StateFlow fun setLocale(languageTag: String) @@ -213,6 +209,10 @@ interface MeshPrefs { fun setDeviceAddress(address: String?) + fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow + + fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) + fun getStoreForwardLastRequest(address: String?): StateFlow fun setStoreForwardLastRequest(address: String?, timestamp: Int) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index b99a002de..2b897baa9 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import okio.ByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Position @@ -26,6 +27,9 @@ import org.meshtastic.proto.LocalConfig /** Interface for sending commands and packets to the mesh network. */ @Suppress("TooManyFunctions") interface CommandSender { + /** Starts the command sender with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Returns the current packet ID. */ fun getCurrentPacketId(): Long diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt index 9f7cbe0dd..dca2a6bf3 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FileService.kt @@ -18,7 +18,7 @@ package org.meshtastic.core.repository import okio.BufferedSink import okio.BufferedSource -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri /** * Abstracts file system operations (like reading from or writing to URIs) so that ViewModels can remain @@ -29,11 +29,11 @@ interface FileService { * Opens a file or URI for writing and provides a [BufferedSink]. The sink is automatically closed after [block] * execution. Returns true if successful, false otherwise. */ - suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean + suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean /** * Opens a file or URI for reading and provides a [BufferedSource]. The source is automatically closed after [block] * execution. Returns true if successful, false otherwise. */ - suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean + suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt index 5c43efdcd..ac92e8287 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MeshUser import org.meshtastic.core.model.Position @@ -24,6 +25,9 @@ import org.meshtastic.core.model.service.ServiceAction /** Interface for handling UI-triggered actions and administrative commands for the mesh. */ @Suppress("TooManyFunctions") interface MeshActionHandler { + /** Starts the handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Processes a service action from the UI. */ suspend fun onServiceAction(action: ServiceAction) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt index b2bb6d418..2a92f8909 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FileInfo import org.meshtastic.proto.MyNodeInfo @@ -23,6 +24,9 @@ import org.meshtastic.proto.NodeInfo /** Interface for managing the configuration flow, including local node info and metadata. */ interface MeshConfigFlowManager { + /** Starts the manager with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Handles received local node information. */ fun handleMyInfo(myInfo: MyNodeInfo) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt index c0e60337e..3f3887631 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -26,6 +27,9 @@ import org.meshtastic.proto.ModuleConfig /** Interface for handling device and module configuration updates. */ interface MeshConfigHandler { + /** Starts the handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Reactive local configuration. */ val localConfig: StateFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt index 9f9851072..eae5bd9a0 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt @@ -16,10 +16,14 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.Telemetry /** Interface for managing the connection lifecycle and status with the mesh radio. */ interface MeshConnectionManager { + /** Starts the connection manager with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Called when the radio configuration has been fully loaded. */ fun onRadioConfigLoaded() @@ -35,6 +39,6 @@ interface MeshConnectionManager { /** Updates the telemetry information for the local node. */ fun updateTelemetry(t: Telemetry) - /** Updates the current status notification. */ - fun updateStatusNotification(telemetry: Telemetry? = null) + /** Updates and returns the current status notification. */ + fun updateStatusNotification(telemetry: Telemetry? = null): Any } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt index 7d5f2a913..2c7487cf9 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt @@ -16,12 +16,16 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import org.meshtastic.core.model.DataPacket import org.meshtastic.proto.MeshPacket /** Interface for handling incoming mesh data packets and routing them to the appropriate handlers. */ interface MeshDataHandler { + /** Starts the handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** * Processes a received mesh packet. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt index a8d6545ce..1a3657d9e 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt @@ -16,10 +16,14 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MeshPacket /** Interface for processing incoming radio messages and mesh packets. */ interface MeshMessageProcessor { + /** Starts the processor with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Handles a raw message received from the radio. */ fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt index 42b306b17..be2830af9 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt @@ -16,8 +16,13 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope + /** Interface for the central router that orchestrates specialized mesh packet handlers. */ interface MeshRouter { + /** Starts the router and its sub-components with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Access to the data handler. */ val dataHandler: MeshDataHandler diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt index a68157943..195a241ee 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.repository -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry @@ -29,7 +28,7 @@ interface MeshServiceNotifications { fun initChannels() - fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) + fun updateServiceStateNotification(state: org.meshtastic.core.model.ConnectionState, telemetry: Telemetry?): Any suspend fun updateMessageNotification( contactKey: String, diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt index 6701514f8..cfda5a9d0 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt @@ -16,33 +16,17 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.core.model.MqttConnectionState -import org.meshtastic.core.model.MqttProbeStatus +import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MqttClientProxyMessage /** Interface for managing MQTT proxy communication. */ interface MqttManager { - /** Observable MQTT proxy connection state for UI consumption. */ - val mqttConnectionState: StateFlow - - /** Starts the MQTT proxy with the given settings. */ - fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) + /** Starts the MQTT manager with the given coroutine scope and settings. */ + fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) /** Stops the MQTT manager. */ fun stop() /** Handles an MQTT proxy message from the radio. */ fun handleMqttProxyMessage(message: MqttClientProxyMessage) - - /** - * Probe an MQTT broker to verify connectivity and credentials without joining the proxy lifecycle. Intended for UI - * "Test Connection" affordances. - * - * @param address Raw broker address as the user would type it (host, host:port, or full URL). - * @param tlsEnabled `true` to upgrade bare addresses to `wss://` (ignored when [address] already has a scheme). - * @param username Optional MQTT username. - * @param password Optional MQTT password. - */ - suspend fun probe(address: String, tlsEnabled: Boolean, username: String?, password: String?): MqttProbeStatus } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt index 903146331..b9759ff59 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt @@ -16,11 +16,15 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NeighborInfo /** Interface for handling neighbor info responses from the mesh. */ interface NeighborInfoHandler { + /** Starts the neighbor info handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Records the start time for a neighbor info request. */ fun recordStartTime(requestId: Int) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index ac6718572..a0d115391 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node @@ -50,6 +51,9 @@ interface NodeManager : NodeIdLookup { /** Sets whether node database writes are allowed. */ fun setAllowNodeDbWrites(allowed: Boolean) + /** Starts the node manager with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** The local node number as a thread-safe [StateFlow]. */ val myNodeNum: StateFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt index 081e2928b..686840f40 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt @@ -16,12 +16,16 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.QueueStatus import org.meshtastic.proto.ToRadio /** Interface for handling the transmission of packets to the radio and managing the packet queue. */ interface PacketHandler { + /** Starts the packet handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Sends a command/packet directly to the radio. */ fun sendToRadio(p: ToRadio) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt index 6bd33a4cf..a0977c582 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt @@ -71,7 +71,7 @@ interface PacketRepository { suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) /** Returns all packets currently queued for transmission. */ - suspend fun getQueuedPackets(): List + suspend fun getQueuedPackets(): List? /** * Persists a packet in the database. diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index cbaf8b3dc..2788a7f07 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -17,7 +17,6 @@ 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 @@ -25,70 +24,26 @@ import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity -/** - * Interface for the low-level radio interface that handles raw byte communication. - * - * This is the **transport layer** — it manages the raw hardware connection (BLE, TCP, Serial, USB) to a Meshtastic - * radio. Its [connectionState] reflects whether the physical link is up or down, **before** any handshake or - * config-loading logic is applied. - * - * **Important:** UI and feature modules should **never** observe [connectionState] directly. Instead, they should use - * [ServiceRepository.connectionState], which is the canonical app-level connection state that accounts for handshake - * progress, light-sleep policy, and other higher-level concerns. The only legitimate consumer of this transport-level - * flow is [MeshConnectionManager], which bridges transport state changes into the app-level - * [ServiceRepository.connectionState]. - * - * @see ServiceRepository.connectionState - */ -interface RadioInterfaceService : RadioTransportCallback { +/** Interface for the low-level radio interface that handles raw byte communication. */ +interface RadioInterfaceService { /** The device types supported by this platform's radio interface. */ val supportedDeviceTypes: List - /** - * Transport-level connection state of the radio hardware. - * - * This flow reflects the raw state of the physical link (BLE, TCP, Serial, USB): - * - [ConnectionState.Connected] — the transport link is established - * - [ConnectionState.Disconnected] — the transport link is down (permanent) - * - [ConnectionState.DeviceSleep] — the transport link is down (transient, device sleeping) - * - * **This is NOT the canonical app-level connection state.** The transport may report [ConnectionState.Connected] - * while the app is still performing the mesh handshake (config + node-info exchange), during which the app-level - * state remains [ConnectionState.Connecting]. - * - * Only [MeshConnectionManager] should observe this flow. All other consumers (ViewModels, feature modules, UI) must - * use [ServiceRepository.connectionState]. - * - * @see ServiceRepository.connectionState - */ + /** Reactive connection state of the radio. */ val connectionState: StateFlow /** Flow of the current device address. */ val currentDeviceAddressFlow: StateFlow - /** Whether we are currently using a mock transport. */ - fun isMockTransport(): Boolean + /** Whether we are currently using a mock interface. */ + fun isMockInterface(): Boolean - /** - * Flow of raw data received from the radio. - * - * Emissions preserve the order in which bytes arrived from the hardware — this is required because the firmware - * handshake (initial config packet ordering) depends on strict FIFO delivery. Implementations MUST guarantee - * ordering; do not swap in a [SharedFlow] without preserving order. - */ - val receivedData: Flow + /** Flow of raw data received from the radio. */ + val receivedData: SharedFlow /** Flow of radio activity events. */ val meshActivity: SharedFlow - /** - * Drains any bytes currently buffered in [receivedData] without emitting them to collectors. - * - * Callers invoke this before attaching a fresh collector after a stop/start cycle so stale bytes buffered while no - * collector was attached do not get replayed ahead of the next session's handshake. - */ - fun resetReceivedBuffer() - /** Sends a raw byte array to the radio. */ fun sendToRadio(bytes: ByteArray) @@ -104,6 +59,15 @@ interface RadioInterfaceService : RadioTransportCallback { /** Constructs a full radio address for the specific interface type. */ fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String + /** Called by an interface when it has successfully connected. */ + fun onConnect() + + /** Called by an interface when it has disconnected. */ + fun onDisconnect(isPermanent: Boolean, errorMessage: String? = null) + + /** Called by an interface when it has received raw data from the radio. */ + fun handleFromRadio(bytes: ByteArray) + /** Flow of user-facing connection error messages (e.g. permission failures). */ val connectionError: SharedFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt index c0572f83f..41015381f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt @@ -16,34 +16,19 @@ */ 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 { +interface RadioTransport : Closeable { /** Sends a raw byte array to the radio hardware. */ fun handleSendToRadio(p: ByteArray) - /** - * Initializes the transport after construction. Called by the factory once the transport has been fully created. - * - * This separates construction from side effects (connecting, launching coroutines), making transports easier to - * test and reason about. - */ - fun start() {} - /** * If we think we are connected, but we don't hear anything from the device, we might be in a zombie state. This * function can be implemented by transports to see if we are really connected. */ fun keepAlive() {} - - /** - * Closes the connection to the device. - * - * Implementations that perform potentially-blocking teardown (e.g. BLE GATT disconnect) MUST run that work inside - * `withContext(NonCancellable)` so a cancelled caller cannot skip cleanup, leaving the underlying resource leaked. - * Callers must invoke this from a coroutine — it must never be called from a blocking context (no `runBlocking`). - */ - suspend fun close() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt deleted file mode 100644 index 9771062a5..000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.repository - -/** - * Narrow callback interface for transport → service communication. - * - * Transport implementations ([RadioTransport]) need only these three methods to report lifecycle events and deliver - * data. This replaces the previous pattern of passing the full [RadioInterfaceService] to transport constructors, - * decoupling transports from the service layer. - */ -interface RadioTransportCallback { - /** Called when the transport has successfully established a connection. */ - fun onConnect() - - /** - * Called when the transport has disconnected. - * - * @param isPermanent true if the device is definitely gone (e.g. USB unplugged, max retries exhausted), false if it - * may come back (e.g. BLE range, TCP transient). - * @param errorMessage optional user-facing error message describing the disconnect reason. - */ - fun onDisconnect(isPermanent: Boolean, errorMessage: String? = null) - - /** Called when the transport has received raw data from the radio. */ - fun handleFromRadio(bytes: ByteArray) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt index c3d2abff1..918657e99 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt @@ -28,8 +28,8 @@ interface RadioTransportFactory { /** The device types supported by this factory. */ val supportedDeviceTypes: List - /** Whether we are currently forced into using a mock transport (e.g., Firebase Test Lab). */ - fun isMockTransport(): Boolean + /** Whether we are currently forced into using a mock interface (e.g., Firebase Test Lab). */ + fun isMockInterface(): Boolean /** Creates a transport for the given [address], or a NOP implementation if invalid/unsupported. */ fun createTransport(address: String, service: RadioInterfaceService): RadioTransport diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 57b1d71ec..4a8af1143 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt @@ -31,39 +31,14 @@ import org.meshtastic.proto.MeshPacket * * This repository acts as the primary data bridge between the long-running mesh service and the UI/Feature layers. It * maintains reactive flows for connection status, error messages, and incoming mesh traffic. - * - * **Connection state contract:** [connectionState] is the **canonical, app-level** connection state that all UI, - * feature modules, and ViewModels should observe. It incorporates handshake progress, light-sleep policy, and transport - * reconciliation — unlike [RadioInterfaceService.connectionState], which only reflects the raw hardware link status. - * The [MeshConnectionManager] is the sole writer of this state; it bridges [RadioInterfaceService.connectionState] - * changes into app-level transitions via [setConnectionState]. - * - * @see RadioInterfaceService.connectionState */ @Suppress("TooManyFunctions") interface ServiceRepository { - /** - * Canonical app-level connection state. - * - * This is the **single source of truth** for connection status across the entire application. All UI components, - * feature modules, and ViewModels should observe this flow — never [RadioInterfaceService.connectionState]. - * - * State transitions are managed exclusively by [MeshConnectionManager], which reconciles transport-level events - * with handshake progress and device sleep policy: - * - [ConnectionState.Disconnected] — no active connection to a radio - * - [ConnectionState.Connecting] — transport is up, mesh handshake (config + node-info) in progress - * - [ConnectionState.Connected] — handshake complete, radio fully operational - * - [ConnectionState.DeviceSleep] — radio entered light-sleep (transient disconnect) - * - * @see RadioInterfaceService.connectionState - */ + /** Reactive flow of the current connection state. */ val connectionState: StateFlow /** - * Updates the canonical app-level connection state. - * - * **This should only be called by [MeshConnectionManager].** Direct mutation from other components would bypass the - * transport-to-app reconciliation logic and create state inconsistencies. + * Updates the current connection state. * * @param connectionState The new [ConnectionState]. */ diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt index bda122ac1..51006763d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt @@ -16,11 +16,15 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.model.DataPacket import org.meshtastic.proto.MeshPacket /** Interface for handling Store & Forward (legacy) and SF++ packets. */ interface StoreForwardPacketHandler { + /** Starts the handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** * Handles a legacy Store & Forward packet. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt index b1f1aa2c9..a53cd8b8a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt @@ -16,11 +16,15 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import org.meshtastic.core.model.DataPacket import org.meshtastic.proto.MeshPacket /** Interface for handling telemetry packets from the mesh, including battery notifications. */ interface TelemetryPacketHandler { + /** Starts the handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** * Processes a telemetry packet. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt index 6535ef30c..aa2e6318a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt @@ -16,11 +16,15 @@ */ package org.meshtastic.core.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import org.meshtastic.proto.MeshPacket /** Interface for handling traceroute responses from the mesh. */ interface TracerouteHandler { + /** Starts the traceroute handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + /** Records the start time for a traceroute request. */ fun recordStartTime(requestId: Int) diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt index 303b8a4ad..dbc951d2a 100644 --- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt +++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt @@ -16,14 +16,13 @@ */ 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`() = runTest { + fun `RadioTransport can be implemented`() { var sentData: ByteArray? = null var closed = false var keepAliveCalled = false @@ -38,7 +37,7 @@ class RadioTransportTest { keepAliveCalled = true } - override suspend fun close() { + override fun close() { closed = true } } diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index 966ab949a..a1ba8fd63 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -25,10 +25,7 @@ kotlin { @Suppress("UnstableApiUsage") android { - androidResources { - enable = true - resourcePrefix = "meshtastic_" - } + androidResources.enable = true withHostTest { isIncludeAndroidResources = true } } diff --git a/core/resources/src/androidMain/res/raw/meshtastic_alert.mp3 b/core/resources/src/androidMain/res/raw/alert.mp3 similarity index 100% rename from core/resources/src/androidMain/res/raw/meshtastic_alert.mp3 rename to core/resources/src/androidMain/res/raw/alert.mp3 diff --git a/core/resources/src/commonMain/composeResources/values-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml index 2e4eaf53c..fc61e78d4 100644 --- a/core/resources/src/commonMain/composeResources/values-ar/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ar/strings.xml @@ -154,7 +154,6 @@ الرسائل إعدادات لورا الجهة - انقطع الاتصال استغرق وقت طويل المسافة الإعدادات @@ -174,5 +173,4 @@ إعدادات بلوتوث - عربي diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml index cb615de37..aee9e7120 100644 --- a/core/resources/src/commonMain/composeResources/values-be/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml @@ -167,8 +167,6 @@ Граць LoRa Рэгіён - Адлучана - Злучаны Імя карыстальніка Пароль Уключана @@ -221,6 +219,4 @@ Чырвоны Сіні Зялёны - Meshtastic - Фільтраваць diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml index f69e137d9..6086edcdf 100644 --- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml @@ -201,15 +201,10 @@ Възстановяване на настройките по подразбиране Приложи Тема - Контраст Светла Тъмна По подразбиране на системата Избор на тема - Ниво на контраста - Стандартен - Среден - Висок Изпращане на местоположение в мрежата Компактно кодиране за Кирилица @@ -255,7 +250,6 @@ Съобщението е доставено Устройството ви може да прекъсне връзката и да се рестартира, докато се прилагат настройките. Грешка - Неизвестна грешка Игнорирай Премахване от игнорирани Добави '%1$s' към списъка с игнорирани? @@ -306,9 +300,9 @@ Батерия Използване на канала Използване на ефира - %1$s: %2$s%% - %1$s: %2$s V - %1$s + %1$s: %2$.1f%% + %1$s: %2$.1f V + %1$.1f %1$s: %2$s записа Брой отскоци @@ -324,9 +318,12 @@ Публичният ключ не съвпада със записания ключ. Можете да премахнете възела и да го оставите да обмени ключове отново, но това може да показва по-сериозен проблем със сигурността. Свържете се с потребителя чрез друг надежден канал, за да определите дали промяната на ключа се дължи на фабрично нулиране или друго умишлено действие. Известия за нови възли SNR + Съотношение сигнал/шум, мярка, използвана в комуникациите за количествено определяне на нивото на желания сигнал спрямо нивото на фоновия шум. В Meshtastic и други безжични системи, по-високото съотношение сигнал/шум показва по-ясен сигнал, който може да подобри надеждността и качеството на предаване на данни. RSSI + Индикатор за силата на получения сигнал - измерване, използвано за определяне на нивото на получения сигнал, приемано от антената. По-високата стойност на RSSI обикновено показва по-силна и по-стабилна връзка. (Качество на въздуха в помещенията) относителна скала за IAQ, стойностите са измерени с Bosch BME680. Диапазон на стойностите 0–500. Метрики на устройството + Карта на възела Позиция Последна актуализация на позицията Показатели на околната среда @@ -351,23 +348,12 @@ Вижте на картата Показване на %1$d/%2$d възела Продължителност: %1$s s - Няма отговор - Натоварване 1m - Натоварване 5m - Натоварване 15m - Средно натоварване на системата за една минута - Средно натоварване на системата за пет минути - Средно натоварване на системата за петнадесет минути - Налична системна памет в байтове 24Ч Макс - Мин - Разгъване на диаграмата - Свиване на диаграмата Неизвестна възраст Копиране Критичен сигнал! @@ -380,11 +366,6 @@ Канал 1 Канал 2 Канал 3 - Канал 4 - Канал 5 - Канал 6 - Канал 7 - Канал 8 Текущ Напрежение Сигурни ли сте? @@ -394,7 +375,6 @@ Известия за изтощена батерия Батерията е изтощена: %1$s Известия за изтощена батерия (любими възли) - Баро Активиран Последно чут: %2$s
Последна позиция: %3$s
Батерия: %4$s]]>
Потребител @@ -470,9 +450,6 @@ Известия при получаване на сигнал/позвъняване Използване на PWM зумер Тон на звънене - Импортирана мелодия - Файлът е празен - Грешка при импортиране: %1$s LoRa Опции Разширени @@ -486,17 +463,6 @@ Честотен слот Игнориране на MQTT Конфигуриране на MQTT - Неактивен - Прекъсната връзка - Свързване… - Свързано - Повторно свързване… - Повторно свързване (опит %1$d) — %2$s - Тестване на връзката - Достъпен. Брокерът е приел идентификационните данни. - Достъпен (%1$s) - Хостът не е намерен - Връзката е неуспешна MQTT е активиран Адрес Потребителско име @@ -549,8 +515,6 @@ Серийната връзка е активирана Echo е активирано Серийна скорост на предаване - RX - TX Сериен режим Брой записи @@ -575,11 +539,6 @@ Налягане Разстояние Вятър - Скорост на вятъра - Порив на вятъра - Посока на вятъра - Дъжд (1ч) - Дъжд (24 ч) Тегло Радиация @@ -706,12 +665,6 @@ Съобщение Въведете съобщение PAX - PAX: %1$d - B:%1$d - W:%1$d - PAX: %1$s - BLE: %1$s - WiFi: %1$s Осигуряване на Wi-Fi за mPWRD-OS Bluetooth устройства Свързано устройство @@ -955,7 +908,6 @@ Забележка Тема: %1$s, Език: %2$s Налични файлове (%1$d): - - %1$s (%2$d байта) Свързване Готово Осигуряване на Wi-Fi за mPWRD-OS @@ -974,9 +926,4 @@ Въведете или изберете мрежа WiFi е конфигуриран успешно! Прилагането на конфигурацията за WiFi не е успешно - Изход - Meshtastic - Филтър - Изберете устройство - Изберете мрежа diff --git a/core/resources/src/commonMain/composeResources/values-ca/strings.xml b/core/resources/src/commonMain/composeResources/values-ca/strings.xml index 22b52e28e..7874fbf89 100644 --- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml @@ -182,7 +182,6 @@ Sempre Traçar ruta Regió - Desconnectat Temps esgotat Distància Meshtastic @@ -200,6 +199,4 @@ - Meshtastic - Filtre diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml index d3e0566ac..51e156e5d 100644 --- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml @@ -216,7 +216,6 @@ Tmavý Podle systému Vyberte vzhled - Vysoká Poskytnout polohu síti Úsporné kódování pro cyriliku @@ -264,7 +263,6 @@ Doručeno Vaše zařízení se může odpojit a restartovat při aplikaci nastavení. Chyba - Neznámá chyba Ignorovat Odstranit z ignorovaných Přidat '%1$s' do seznamu ignorovaných? @@ -313,7 +311,6 @@ Baterie ChUtil AirUtil - %1$s %1$s: %2$s Teplota Vlhkost @@ -331,9 +328,12 @@ Informace o uživateli Oznámení o nových uzlech SNR + Poměr signálu k šumu (SNR) je veličina používaná k vyjádření poměru mezi úrovní požadovaného signálu a úrovní šumu na pozadí. V Meshtastic a dalších bezdrátových systémech vyšší hodnota SNR značí čistší signál, což může zvýšit spolehlivost a kvalitu přenosu dat. RSSI + Indikátor síly přijímaného signálu, měření, které se používá k určení hladiny výkonu přijímané anténou. Vyšší hodnota RSSI obvykle znamená silnější a stabilnější spojení. (Vnitřní kvalita ovzduší) relativní hodnota IAQ měřená Bosch BME680. Hodnota rozsahu 0–500. Metriky zařízení + Mapa uzlu Pozice Poslední aktualizace pozice Metriky prostředí @@ -522,8 +522,6 @@ Ignorovat MQTT OK do MQTT Nastavení MQTT - Odpojeno - Připojeno MQTT povoleno Adresa Uživatelské jméno @@ -967,6 +965,4 @@ Poznámka Připojit Hotovo - Meshtastic - Filtr diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index 4755515ad..8a344ff18 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -253,15 +253,10 @@ Auf Standardeinstellungen zurücksetzen Anwenden Design - Kontrast Hell Dunkel System Design auswählen - Kontrast - Standard - Medium Fast - Hoch Standort zum Mesh angeben Kompakte Kodierung für Kyrillisch @@ -307,7 +302,6 @@ Zustellung bestätigt Ihr Gerät kann die Verbindung trennen und neu starten, während die Einstellungen angewendet werden. Fehler - Unbekannter Fehler Ignorieren Aus Ignorierliste entfernen '%1$s' zur Ignorieren-Liste hinzufügen? @@ -359,9 +353,9 @@ Akku Kanalauslastung Sendezeit - %1$s: %2$s%% - %1$s: %2$s V - %1$s + %1$s: %2$.1f%% + %1$s: %2$.1f V + %1$.1f %1$s: %2$s Temperatur Feuchtigkeit @@ -383,9 +377,12 @@ Benutzerinfo Benachrichtigung neue Knoten SNR + Signal-Rausch-Verhältnis, ein in der Kommunikation verwendetes Maß, um den Pegel eines gewünschten Signals im Verhältnis zum Pegel des Hintergrundrauschens zu quantifizieren. Bei Meshtastic und anderen drahtlosen Systemen weist ein höheres SNR auf ein klareres Signal hin, das die Zuverlässigkeit und Qualität der Datenübertragung verbessern kann. RSSI + Indikator für die empfangene Signalstärke, eine Messung zur Bestimmung der von der Antenne empfangenen Leistungsstärke. Ein höherer RSSI-Wert weist im Allgemeinen auf eine stärkere und stabilere Verbindung hin. (Innenluftqualität) relativer IAQ-Wert gemessen von Bosch BME680. Gerätedaten + Standortkarte Knoten Standort Letzte Standortaktualisierung Umweltdaten @@ -414,26 +411,13 @@ Dauer: %1$s s Route zum Zielort:\n\n Route zurück zu uns:\n\n - Sprungweite Hinweg - Sprungweite Rückweg - Rundstrecke Keine Antwort - Last 1 Min. - Last 5 Min. - Last 15 Min. - Durchschnittliche Systemlast von 1 Minute - Durchschnittliche Systemlast von 5 Minuten - Durchschnittliche Systemlast von 15 Minuten - Verfügbarer Systemspeicher in Bytes 1 Stunde 24H 1 Woche 2 Wochen 1 Monat Maximal - Minimum - Diagramm einblenden - Diagramm ausblenden Alter unbekannt Kopie Warnklingelzeichen! @@ -447,11 +431,6 @@ Kanal 1 Kanal 2 Kanal 3 - Kanal 4 - Kanal 5 - Kanal 6 - Kanal 7 - Kanal 8 Strom Spannung Sind Sie sicher? @@ -583,9 +562,6 @@ Ausgabedauer (GPIO) Nervige Verzögerung (Sekunden) Klingelton - Importierter Klingelton - Datei ist leer - Fehler beim Importieren: %1$s Wiedergabe I2S als Buzzer verwenden LoRa @@ -609,23 +585,6 @@ MQTT ignorieren OK für MQTT MQTT Einstellungen - Inaktiv - Verbindung getrennt - Verbindung getrennt - %1$s - Wird verbunden - Verbunden - Erneut verbinden - Erneut verbinden (Versuch %1$d) - %2$s - Verbindung testen - Broker prüfen. - Erreichbar. Broker akzeptierte Anmeldedaten. - Erreichbar (%1$s) - Broker abgelehnt: %1$s - Host nicht gefunden - Broker (TCP) nicht erreichbar - TLS Handshake fehlgeschlagen - Zeitüberschreitung nach %1$d ms - Verbindung fehlgeschlagen MQTT aktiviert Adresse Benutzername @@ -697,8 +656,6 @@ Serielle Schnittstelle aktiviert Echo aktiviert Serielle Baudrate - Empfang - Senden Zeitlimit erreicht Serieller Modus Seriellen Anschluss der Konsole überschreiben @@ -734,14 +691,8 @@ Lux Wind Windgeschwindigkeit - Windböen - Windstille - Windrichtung - Regen (1 Std.) - Regen (24 Std.) Gewicht Strahlung - 1-Wire Temperature Luftqualität im Innenbereich (IAQ) URL @@ -881,12 +832,6 @@ Eine Nachricht schreiben Benutzerzählerdaten Besucher - Besucher: %1$d - B:%1$d - W:%1$d - Besucher: %1$s - BLE: %1$s - WLAN: %1$s Keine Daten für den Besucherzähler verfügbar. WLAN Unterstützung für mPWRD-OS Bluetooth Geräte @@ -1221,21 +1166,4 @@ Netzwerk eingeben oder auswählen WLAN erfolgreich konfiguriert! WLAN Konfiguration konnte nicht angewendet werden - Meshtastic Desktop - Meshtastic anzeigen - Beenden - Meshtastic - TAK Datenpaket exportieren - Zeitzone löschen - Filter - Filter entfernen - Legende für Luftqualität anzeigen - Nachrichtenstatus anzeigen - Antwort senden - Nachricht kopieren - Nachricht auswählen - Nachricht löschen - Mit Emoji reagieren - Gerät auswählen - Wählen Sie ein Netzwerk diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml index 8386ac2ea..88feab55e 100644 --- a/core/resources/src/commonMain/composeResources/values-el/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml @@ -164,7 +164,6 @@ Μηνύματα LoRa Περιφέρεια - Αποσυνδεδεμένο Διεύθυνση Όνομα χρήστη Κωδικός πρόσβασης @@ -201,5 +200,4 @@ Κόκκινο Μπλε Πράσινο - Φίλτρο diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml index 4c59aa547..7b9ca263e 100644 --- a/core/resources/src/commonMain/composeResources/values-es/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml @@ -300,10 +300,13 @@ Clave pública no coincide Notificaciones de nuevo nodo SNR + SNR: Ratio de señal a ruido, una medida utilizada en las comunicaciones para cuantificar el nivel de una señal deseada respecto al nivel del ruido de fondo. En Meshtastic y otros sistemas inalámbricos, un mayor SNR indica una señal más clara que puede mejorar la fiabilidad y la calidad de la transmisión de datos. RSSI + Indicador de Fuerza de Señal Recibida (RSSI en inglés), una medida utilizada para determinar el nivel de potencia que está siendo recibido por la antena. Un valor de RSSI más alto generalmente indica una conexión más fuerte y estable. (Calidad de Aire interior) escala relativa del valor IAQ como mediciones del sensor Bosch BME680. Rango de Valores 0 - 500. Métricas de Dispositivo + Mapa de Nodos Posición Última actualización Métricas de Entorno @@ -489,8 +492,6 @@ Rango de Valores 0 - 500.
Ignorar Paquetes MQTT Permitir MQTT Configuración MQTT - Desconectado - Conectado Activar el MQTT Dirección del Servidor MQTT Usuario @@ -835,6 +836,4 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Verde Conectar Hecho - Meshtastic - Filtro diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml index c2e327629..6c4b32bc8 100644 --- a/core/resources/src/commonMain/composeResources/values-et/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml @@ -253,15 +253,10 @@ Taasta vaikesätted Rakenda Teema - Kontrastsus Hele Tume Süsteemi vaikesäte Vali teema - Kontrastsuse tase - Standard - Keskmine - Kõrge Jaga telefoni asukohta mesh-võrku Kompaktne kodeering kirillitsa jaoks @@ -307,7 +302,6 @@ Kohale toimetatud Seadete rakendamise ajal võib seadme ühendus katkeda ja taaskäivituda. Viga - Tundmatu viga Eira Eemalda ignoreeritute hulgast Lisa '%1$s' eiramis loendisse? @@ -359,9 +353,9 @@ Aku Kanali kasutus Saate kasutus - %1$s: %2$s%% - %1$s: %2$s V - %1$s + %1$s: %2$.1f%% + %1$s: %2$.1f V + %1$.1f %1$s: %2$s Temperatuur Niiskus @@ -383,9 +377,12 @@ Kasutaja teave Uue sõlme teade SNR + Signaali ja müra suhe (SNR) on mõõdik, mida kasutatakse soovitud signaali taseme ja taustamüra taseme vahelise suhte määramisel. Meshtastic ja teistes traadita süsteemides näitab kõrgem signaali ja müra suhe selgemat signaali, mis võib parandada andmeedastuse usaldusväärsust ja kvaliteeti. RSSI + Vastuvõetud signaali tugevuse indikaator (RSSI), mõõt mida kasutatakse antenni poolt vastuvõetava võimsustaseme määramiseks. Kõrgem RSSI väärtus näitab üldiselt tugevamat ja stabiilsemat ühendust. Siseõhu kvaliteet (IAQ) on suhtelise skaala väärtus, näiteks mõõtes Bosch BME680 abil. Väärtuste vahemik 0–500. Seadme mõõdikud + Sõlmede kaart Asukoht Viimase asukoha värskendus Keskkonnamõõdikud @@ -432,6 +429,7 @@ 1k Maksimaalselt Min + Keskm Laienda diagrammi Ahenda diagrammi Tundmatu vanus @@ -583,9 +581,6 @@ Väljundi kestvus (millisekundit) Häire ajalõpp (sekundit) Helin - Imporditud helin - Fail on tühi - Viga importimisel: %1$s Mängi ette Kasuta I2S summerina LoRa @@ -609,23 +604,6 @@ Keela MQTT Ok MQTTi MQTT sätted - Mitteaktiivne - Ühendus katkenud - Ühendus katkenud — %1$s - Ühendan… - Ühendatud - Taas ühendan… - Ühendan uuesti (katse %1$d) — %2$s - Test ühendus - Kontrollin vahendajat… - Ühendus õnnestus. Vahendaja aktsepteeris kasutajateave. - Kättesaadav (%1$s) - Vahendaja lükkas tagasi: %1$s - Hosti ei leitud - Vahendajaga ei saa ühendust (TCP) - TLS ühendus ebaõnnestus - Ajaline katkestus peale %1$d ms - Ühendus ebaõnnestus MQTT lubatud Aadress Kasutajatunnus @@ -741,7 +719,6 @@ Vihm (24h) Kaal Radiatsioon - 1-juhtmeline temperatuur Siseõhu kvaliteet (IAQ) URL @@ -881,12 +858,6 @@ Sisesta sõnum Pax mõõdiku logi PAX - PAX: %1$d - B:%1$d - W:%1$d - PAX: %1$s - BLE: %1$s - WiFi: %1$s Pax mõõdikut pole saadaval. WiFi ühenduse loomine mPWRD-OS-i jaoks Sinihamba seade @@ -1221,21 +1192,4 @@ Sisestage või valige võrk WiFi edukalt seadistatud! WiFi sätete rakendamine ebaõnnestus - Meshtastic töölaud - Näita Meshtastic - Sule - Kärgvõrgustik - Ekspordi TAK andmepakett - Eemalda ajatsoon - Filtreeri - Eemalda filter - Näita õhukvaliteedi ajalugu - Kuva sõnumi olek - Saada vastus - Kopeeri sõnum - Vali sõnum - Kustuta sõnum - Vasta emotikoniga - Vali seade - Vali võrk diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml index f9da71dea..8685b0380 100644 --- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml @@ -253,15 +253,10 @@ Palauta oletusasetukset Hyväksy Teema - Kontrasti Vaalea Tumma Järjestelmän oletus Valitse teema - Kontrastin taso - Normaali - Keskitaso - Korkea Jaa puhelimen sijaintitietoa mesh-verkkoon Kyrillisten merkkien tiivis koodaus @@ -307,7 +302,6 @@ Toimitus vahvistettu Laitteesi saattaa katkaista yhteyden ja käynnistyä uudelleen, kun asetuksia otetaan käyttöön. Virhe - Tuntematon virhe Jätä huomiotta Poista huomioimattomista Lisää '%1$s' jätä huomiotta listalle? Laite käynnistyy uudelleen muutoksen tekemisen jälkeen. @@ -359,9 +353,9 @@ Akku Kanavan käyttöaste Lähetysajan käyttöaste - %1$s: %2$s%% - %1$s: %2$s V - %1$s + %1$s: %2$.1f%% + %1$s: %2$.1f V + %1$.1f %1$s: %2$s Lämpötila Kosteus @@ -383,9 +377,12 @@ Käyttäjätiedot Uuden laitteen ilmoitukset SNR + Signaali-kohinasuhde (SNR) on mittari, jota käytetään viestinnässä halutun signaalin tason ja taustahälyn tason määrittämisessä. Meshtasticissa ja muissa langattomissa järjestelmissä korkeampi SNR tarkoittaa selkeämpää signaalia, joka voi parantaa tiedonsiirron luotettavuutta ja laatua. RSSI + Vastaanotetun signaalin voimakkuusindikaattori (RSSI) on mittari, jota käytetään määrittämään antennilla vastaanotetun signaalin voimakkuus. Korkeampi RSSI-arvo yleensä osoittaa vahvemman ja vakaamman yhteyden. Sisäilman laatu (IAQ) on suhteellinen asteikko, jota voidaan mitata mm. Bosch BME680 anturilla ja sen arvoväli on 0–500. Laitteen mittausloki + Laitekartta Sijainti Viimeisin sijainnin päivitys Ympäristöarvot @@ -432,6 +429,7 @@ 1 kk Kaikki Minimi + Keskiarvo Laajenna kaavio Pienennä kaavio Tuntematon ikä @@ -583,9 +581,6 @@ Ulostulon kesto (millisekuntia) Hälytysaikakatkaisu (sekuntia) Soittoääni - Tuotu soittoääni - Tiedosto on tyhjä - Virhe tuotaessa: %1$s Aloita Käytä I2S protokollaa äänimerkille LoRa @@ -609,23 +604,6 @@ Ohita MQTT MQTT päällä MQTT asetukset - Passiivinen - Ei yhdistetty - Yhteys katkaistu — %1$s - Yhdistetään… - Yhdistetty - Yhdistetään uudelleen… - Yhdistetään uudelleen (yritys %1$d) — %2$s - Testaa yhteys - Tarkistetaan välityspalvelinta… - Yhteys onnistui. Välityspalvelin hyväksyi tunnistetiedot. - Yhteys onnistui (%1$s) - Välityspalvelin ei hyväksynyt: %1$s - Palvelinta ei löytynyt - Yhteyttä välityspalvelimeen ei saada (TCP) - TLS-yhteyden muodostus epäonnistui - Aikakatkaistu %1$d ms jälkeen - Yhdistäminen epäonnistui MQTT käytössä Osoite Käyttäjänimi @@ -741,7 +719,6 @@ Sademäärä (24 h) Paino Säteily - Lämpötila (1-Wire) Sisäilmanlaatu (IAQ) URL-osoite @@ -881,12 +858,6 @@ Kirjoita viesti Pax mittarit PAX - PAX: %1$d - B:%1$d - W:%1$d - PAX: %1$s - BLE: %1$s - WiFi: %1$s PAX mittareita ei ole saatavilla. WiFi-määritys mPWRD-OS:lle Bluetooth-laitteet @@ -1222,21 +1193,4 @@ Syötä tai valitse verkko WiFi määritetty onnistuneesti! WiFi-asetusten käyttöönotto epäonnistui - Meshtastic työpöytä - Näytä Meshtastic - Lopeta - Meshtastic - Vie TAK-datapaketti - Tyhjennä aikavyöhyke - Suodatus - Poista suodatin - Näytä ilmanlaadun selite - Näytä viestin tila - Lähetä vastaus - Kopioi viesti - Valitse viesti - Poista viesti - Reaktio emojin kanssa - Valitse laite - Valitse verkko diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml index f4afeef5c..fe1a9aaef 100644 --- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml @@ -18,7 +18,6 @@ Meshtastic - Meshtastic %1$s Filtre Effacer le filtre de nœud Filtrer par @@ -41,11 +40,9 @@ Interne par Favoris Afficher uniquement les nœuds ignorés - Exclure MQTT Non reconnu En attente d'accusé de réception En file d'attente pour l'envoi - Délivré au nœud Inconnu Routage via chaîne SF++… Confirmé via chaîne SF++ @@ -122,8 +119,7 @@ Distance minimale en mètres pour considérer une diffusion de position intelligente. À quelle fréquence devrions-nous essayer d'obtenir une position GPS (<10sec le GPS est maintenu allumé). Champs optionnels à inclure dans les messages de position. Plus il y en a, plus le message est grand, plus cela augmentant le temps d'occupation du réseau et le risque de perte. - Sera en veille profonde autant que possible, pour les rôles traceurs et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. - Généré à partir de votre clé publique et envoyé à d'autres nœuds sur le maillage pour leur permettre de calculer une clé secrète partagée. + Sera en veille profonde autant que possible, pour les rôles traqueur et capteur, cela inclura également la radio LoRa. N'utilisez pas ce paramètre si vous voulez utiliser votre appareil avec les applications de téléphone ou si vous utilisez un appareil sans bouton utilisateur. Utilisée pour créer une clé partagée avec un appareil distant. Clé publique autorisée à envoyer des messages d’administration à ce nœud. L'appareil est géré par un administrateur de maillage, l'utilisateur ne peut accéder à aucun des paramètres de l'appareil. @@ -167,25 +163,18 @@ Port : Connecté Connexions actuelles : - IP du Wifi : + IP WiFi : IP Ethernet : Connexion en cours Non connecté Aucun appareil sélectionné Périphérique inconnu - Aucun périphérique réseau trouvé - Pas de périphérique USB trouvé - USB - Mode Démo Connecté à la radio, mais en mode veille Mise à jour de l’application requise Vous devez mettre à jour cette application sur l'app store (ou Github). Il est trop vieux pour dialoguer avec le micrologiciel de la radio. Veuillez lire nos docs sur ce sujet. Aucun (désactivé) Notifications de service Remerciements - Bibliothèques Open Source - Meshtastic est construit avec les bibliothèques open source suivantes. Appuyez sur n'importe quelle bibliothèque pour voir sa licence. - %1$d Bibliothèques Cette URL de canal est invalide et ne peut pas être utilisée Panneau de débogage Contenu décodé : @@ -218,21 +207,7 @@ Correspondre à tout | N'importe quel Cela supprimera tous les paquets de journaux et les entrées de la base de données de votre appareil - c'est une réinitialisation complète, et est permanent. Effacer - Rechercher des émojis... - Plus d'actions Canal - %1$s: %2$s - Message de %1$s: %2$s - Entête - Élément %1$d - Pied de page - Exporter le paquet de données TAK - Point - Texte - Jauge - Dégradé - Ceci est un composable personnalisé - Avec plusieurs lignes et styles Statut d'envoi du message Nouveaux messages au-dessous Notifications de message @@ -253,15 +228,10 @@ Rétablir les valeurs par défaut Appliquer Thème - Contraste Clair Sombre Valeur par défaut du système Choisir un thème - Niveau de contraste - Standard - Milieu - Haut Fournir l'emplacement au maillage Encodage compact pour Cyrillique @@ -305,9 +275,7 @@ Message direct Reconfiguration de NodeDB Réception confirmée par le destinataire - Votre appareil peut se déconnecter et redémarrer lorsque les paramètres sont appliqués. Erreur - Une erreur inconnue s'est produite Ignorer Supprimer des ignorés Ajouter '%1$s' à la liste des ignorés ? Votre radio va redémarrer après avoir effectué ce changement. @@ -348,8 +316,6 @@ Actuellement : Toujours muet Non muet - Muet pour %1$d jours, %2$s heures - Muet pour %1$s heures Désactiver les notifications pour '%1$s' ? Réactiver les notifications pour '%1$s' ? Remplacer @@ -359,10 +325,6 @@ Batterie UtilCanal UtilAir - %1$s / %2$s%% - %1$s: %2$s V - %1$s - %1$s: %2$s Temp Hum Temp sol @@ -383,9 +345,12 @@ Infos utilisateur Notifikasyon nouvo nœud SNR + Signal-to-Noise Ratio, une mesure utilisée dans les communications pour quantifier le niveau du signal par rapport au niveau du bruit de fond. Dans les systèmes Meshtastic et autres systèmes sans fil, un SNR plus élevé indique un signal plus clair qui peut améliorer la fiabilité et la qualité de la transmission de données. RSSI + Indicateur de force du signal reçu, une mesure utilisée pour déterminer le niveau de puissance reçu par l'antenne. Une valeur RSSI plus élevée indique généralement une connexion plus forte et plus stable. (Qualité de l'air intérieur) valeur de l'échelle relative IAQ mesurée par Bosch BME680. Plage de valeur 0–500. Métriques de l’appareil + Carte historique des positions Position Dernière mise à jour de position Métriques d'environnement @@ -414,26 +379,13 @@ Durée : %1$s s Route aller :\n\n Route retour :\n\n - Saut vers l'avant - Saut vers l'arrière - Aller/Retour Pas de réponse - Charge 1 m - Charge 5m - Charge 15 m - Moyenne de charge du système d'une minute - Moyenne de charge du système de cinq minutes - Moyenne de charge du système de 15 minutes - Mémoire système disponible en octets 1H 24H 1S 2S 1M Max - Min - Agrandir le graphique - Réduire le graphique Age inconnu Copier Caractère d'appel ! @@ -447,17 +399,11 @@ Canal 1 Canal 2 Canal 3 - Canal 4 - Canal 5 - Canal 6 - Canal 7 - Canal 8 Actif Tension Êtes-vous sûr ? Documentation du rôle de l'appareil et le billet de blog sur comment Choisir le rôle de l'appareil approprié.]]> Je sais ce que je fais. - La batterie du nœud %1$s est faible (%2$d%) Notifications de batterie faible Batterie faible : %1$s Notifications de batterie faible (nœuds favoris) @@ -583,9 +529,6 @@ Durée de sortie (en millisecondes) Durée de répétition de la sortie (secondes) Sonnerie - Sonnerie importée - Le fichier est vide - Erreur d'importation : %1$s Lancer Utiliser l'I2S comme buzzer LoRa @@ -609,13 +552,6 @@ Ignorer MQTT Transmission des paquets vers MQTT Configuration MQTT - Inactif - Déconnecté - Connexion… - Connecté - Reconnexion… - Test de la connexion - Échec de la connexion MQTT activé Adresse Nom d'utilisateur @@ -687,8 +623,6 @@ Série activée Écho activé Vitesse de transmission série - RX - Tx Délai d'expiration Mode série Outrepasser le port série de la console @@ -723,15 +657,8 @@ Distance Lux Vent - Vitesse du vent - Rafales de vent - Vent à la traîne - Direction du vent - Pluie (1h) - Pluie (24h) Poids Radiation - Températeur 1-Wire Qualité de l'air intérieur (IAQ) URL @@ -748,7 +675,6 @@ Horodatage En-tête Vitesse - %1$d Km/h Sats Alt Fréq @@ -814,11 +740,6 @@ Afficher les points de repère Afficher les cercles de précision Notification client - Vérification de la clé - Requête de vérification de clé - Vérification de la clé terminée - Clé publique dupliquée détectée - Clé de chiffrement faible détectée Clés compromises détectées, sélectionnez OK pour régénérer. Régénérer la clé privée Êtes-vous sûr de vouloir régénérer votre clé privée ?\n\nLes nœuds qui peuvent avoir précédemment échangé des clés avec ce nœud devront supprimer ce nœud et ré-échanger des clés afin de reprendre une communication sécurisée. @@ -871,14 +792,7 @@ Composer un message Métriques de PAX PAX - PAX : %1$d - B:%1$d - W :%1$d - PAX : %1$s - BLE: %1$s - Wi-Fi : %1$s Aucune métrique PAX disponible. - Approvisionnement Wi-Fi pour mPWRD-OS Appareils Bluetooth Périphérique connecté Limite de débit dépassée. Veuillez réessayer plus tard. @@ -933,8 +847,6 @@ Terrain Hybride Gérer les calques de la carte - Les calques personnalisés prennent en charge les fichiers .kml, .kmz ou GeoJSON. - Aucun calque personnalisé chargé. Ajouter un calque Afficher le calque Supprimer le calque @@ -942,10 +854,6 @@ Nœuds à cet emplacement Type de carte sélectionné Gérer les sources de tuiles personnalisées - Ajouter un réseau de tuile personnalisée - Aucune source de tuiles personnalisées trouvée. - Modifier le réseau de tuile personnalisée - Supprimer le réseau de tuile personnalisée Le nom ne peut pas être vide. Le nom du fournisseur existe déjà. URL ne peut être vide. @@ -1039,7 +947,6 @@ Notes de Version Une erreur inconnue s'est produite Les informations de l'utilisateur du nœud sont manquantes. - Batterie trop faible (%1$d%). Veuillez charger votre appareil avant de mettre à jour. Impossible de récupérer le fichier firmware. Échec de la mise à jour USB Intégrité (hash) du firmware rejetée. Veuillez réessayer ou mettre à jours l'appareil via USB. @@ -1116,10 +1023,8 @@ Configuration Gérer à distance sans fil les paramètres et les canaux de votre appareil. Sélection du style de carte - Batterie : %1$d% Nœuds : %1$d en ligne / %2$d au total Temps de disponibilité : %1$s - ChUtil: %1$s% | AirTX: %2$s% Trafic : TX %1$d / RX %2$d (D: %3$d) Relais : %1$d (annulé: %2$d) Diagnostiques : %1$s @@ -1133,94 +1038,10 @@ Actualiser Mis à jour - Ajouter une couche de réseau - Fichier local MBTiles - Ajouter un fichier local MBTiles - TAK (ATAK) - Configuration TAK - Activer le serveur TAK local - Démarre un serveur TCP sur le port 8089 pour les connexions ATAK - Couleur de l'équipe - Rôle Membre - Non spécifié - Blanc - Jaune - Orange - Magenta Rouge - Marron - Pourpre - Bleu foncé Bleu - Cyan - Turquoise Vert - Vert Foncé - Marron - Non spécifié - Membre de l'équipe - Chef d'équipe - Quartier général - Tireur d'élite - Medic - Observateur de transfert - Opérateur de radio téléphonie - Doggo (K9) - Gestion du trafic - Configuration de la gestion du trafic Module activé - Déduplication de Position - Précision de position (octets) - Intervalle de position min (secs) - Réponse directe de NodeInfo - Max de saut pour une réponse directe - Limitation de débit - Fenêtre de limitation de taux (secs) - Paquets maximum dans la fenêtre - Ignorer les paquets inconnus - Seuil de paquets inconnu - Télémétrie locale uniquement (Relays) - Position locale uniquement (Relays) - Conserver les sauts du Routeur - Note - Stockage de l'appareil & UI (lecture seule) - Thème %1$s, Langue %2$s - Fichiers disponibles (%1$d ) : - - %1$s (%2$d octets) - Aucun fichier affiché. Connecter Terminé - Approvisionnement Wi-Fi pour mPWRD-OS - Fournissez les identifiants Wi-Fi à votre appareil mPWRD-OS via Bluetooth. - En savoir plus sur le projet mPWRD-OS\nhttps://github.com/mPWRD-OS - Recherche de l'appareil - Appareil détecté - Prêt à rechercher des réseaux WiFi. - Rechercher des réseaux - Recherche… - Application de la configuration WiFi… - Aucun réseau trouvé - Impossible de se connecter : %1$s - Échec de la recherche des réseaux WiFi : %1$s - %1$d% - Réseaux disponibles - Nom du réseau (SSID) - Saisir ou sélectionnez un réseau - WiFi configuré avec succès ! - Impossible d'appliquer la configuration WiFi - Meshtastic application de bureau - Afficher Meshtastic - Quitter - Meshtastic - Exporter le paquet de données TAK - Filtre - Supprimer le filtre - Afficher le statut du message - Envoyer une réponse - Copier le message - Sélectionner le message - Supprimer le message - Réagir avec un emoji - Sélectionner l'appareil - Sélectionner le réseau diff --git a/core/resources/src/commonMain/composeResources/values-ga/strings.xml b/core/resources/src/commonMain/composeResources/values-ga/strings.xml index baabf41d0..a081daff2 100644 --- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml @@ -190,7 +190,10 @@ Cóid Poiblí Eochair Mícomhoiriúnacht na heochrach phoiblí Fógartha faoi na nodes nua + Ráta Sigineal go Torann, tomhas a úsáidtear i gcomhfhreagras chun an leibhéal de shígnéil inmhianaithe agus torann cúlra a mheas. I Meshtastic agus i gcórais gan sreang eile, ciallaíonn SNR níos airde go bhfuil sígneál níos soiléire ann agus ábalta méadú ar chreideamh agus cáilíocht an tarchur sonraí. + Táscaire Cumhachta Athnuachana Aithint an Aoise, tomhas a úsáidtear chun leibhéal cumhachta atá faighte ag an antsnáithe a mheas. Léiríonn RSSI níos airde gnóthachtáil níos laige atá i gceangal seasmhach agus níos láidre. (Cáilíocht Aeir Inmheánach) scála ábhartha den luach QAÍ a thomhas ag Bosch BME680. Scála Luach 0–500. + Léarscáil an Node Rialachas Rialú iargúlta Go dona @@ -210,7 +213,6 @@ Céimeanna i dtreo %1$d Céimeanna ar ais %2$d Réigiún - Na ceangailte Am tráth Sáth @@ -227,5 +229,4 @@ - Scagaire diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml index dc751d2e9..cc3c02597 100644 --- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml @@ -149,7 +149,6 @@ Sempre Traza-ruta Rexión - Desconectado Distancia @@ -165,5 +164,4 @@ - Filtro diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml index 502d64056..3afe39071 100644 --- a/core/resources/src/commonMain/composeResources/values-he/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml @@ -133,7 +133,6 @@ בדיקת מסלול הודעות אזור - מנותק מרחק הגדרות @@ -148,5 +147,4 @@ - פילטר diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml index 114c3ed9a..f049338ae 100644 --- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml @@ -150,7 +150,6 @@ Detalji Crveno Regija - Odspojeno Udaljenost Meshtastic @@ -168,6 +167,4 @@ Crveno - Meshtastic - Filtriraj diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml index 60e00d491..7c4fc0f24 100644 --- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml @@ -186,7 +186,10 @@ Chifreman Kle Piblik Pa matche kle piblik Notifikasyon nouvo nœud + Rapò Siynal sou Bri, yon mezi ki itilize nan kominikasyon pou mezire nivo siynal vle a kont nivo bri ki nan anviwònman an. Nan Meshtastic ak lòt sistèm san fil, yon SNR pi wo endike yon siynal pi klè ki ka amelyore fyab ak kalite transmisyon done. + Endikatè Fòs Siynal Resevwa, yon mezi ki itilize pou detèmine nivo pouvwa siynal ki resevwa pa antèn nan. Yon RSSI pi wo jeneralman endike yon koneksyon pi fò ak plis estab. (Kalite Lèy Entèryè) echèl relatif valè IAQ jan li mezire pa Bosch BME680. Ranje valè 0–500. + Kat Nœud Administrasyon Administrasyon Remote Move @@ -198,7 +201,6 @@ Direk Hops vèsus %1$d Hops tounen %2$d Rejyon - Dekonekte Tan pase Distans @@ -215,5 +217,4 @@ - Filtre diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml index 33b795a7f..c8d27cf4a 100644 --- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml @@ -316,9 +316,12 @@ Publikus kulcs nem egyezik Új állomás értesítések SNR + Jel–zaj arány (SNR): a kommunikációban a kívánt jel szintjének és a háttérzaj szintjének aránya. A Meshtastic és más vezeték nélküli rendszerek esetében a magasabb SNR tisztább jelet jelent, ami javítja az adatátvitel megbízhatóságát és minőségét. RSSI + Vett jelerősség-mutató (RSSI): az antenna által vett jel teljesítményszintjének mérése. A magasabb RSSI általában erősebb, stabilabb kapcsolatot jelez. (Beltéri levegőminőség) relatív IAQ érték a Bosch BME680 szenzor alapján. Értéktartomány: 0–500. Eszközmetrikák + Állomás Térkép Pozíció Utolsó pozíciófrissítés Környezeti metrikák @@ -512,8 +515,6 @@ MQTT figyelmen kívül hagyása MQTT-re továbbítható MQTT beállítások - Szétkapcsolva - Csatlakoztatva MQTT engedélyezve Cím Felhasználónév @@ -849,6 +850,4 @@ Kék Zöld Csatlakozás - Meshtastic - Filter diff --git a/core/resources/src/commonMain/composeResources/values-is/strings.xml b/core/resources/src/commonMain/composeResources/values-is/strings.xml index ce8853250..4e07e1c2a 100644 --- a/core/resources/src/commonMain/composeResources/values-is/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-is/strings.xml @@ -119,7 +119,6 @@ Hámarsksendingartíma náð. Ekki hægt að senda skilaboð, vinsamlegast reynið aftur síðar. Ferilkönnun Svæði - Aftengd diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml index baa0e0947..8e9066c22 100644 --- a/core/resources/src/commonMain/composeResources/values-it/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml @@ -239,8 +239,6 @@ Scuro Predefinito di sistema Scegli tema - Medium - Alto Fornire la posizione alla mesh Codifica compatta per cirillico @@ -286,7 +284,6 @@ Consegna confermata Il dispositivo potrebbe disconnettersi e riavviarsi durante l'applicazione delle impostazioni. Errore - Errore sconosciuto Ignora Rimuovi da ignorati Aggiungere '%1$s' alla lista degli ignorati? @@ -355,9 +352,12 @@ Informazioni Utente Notifiche di nuovi nodi SNR + Rapporto segnale-rumore (Signal-to-Noise Ratio), una misura utilizzata nelle comunicazioni per quantificare il livello di un segnale desiderato rispetto al livello di rumore di fondo. In Meshtastic e in altri sistemi wireless, un SNR più elevato indica un segnale più chiaro che può migliorare l'affidabilità e la qualità della trasmissione dei dati. RSSI + Indicatore di forza del segnale ricevuto (Received Signal Strength Indicator), una misura utilizzata per determinare il livello di potenza ricevuto dall'antenna. Un valore RSSI più elevato indica generalmente una connessione più forte e più stabile. (Qualità dell'aria interna) scala relativa del valore della qualità dell'aria indoor, misurato da Bosch BME680. Valore Intervallo 0–500. Metriche Dispositivo + Mappa Dei Nodi Posizione Aggiornamento ultima posizione Metriche Ambientali @@ -557,8 +557,6 @@ Ignora MQTT OK per MQTT Configurazione MQTT - Disconnesso - Connesso MQTT abilitato Indirizzo Username @@ -959,6 +957,4 @@ Note Connetti Fatto - Meshtastic - Filtro diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml index 64aa0fe05..5b53fd292 100644 --- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml @@ -263,7 +263,6 @@ WiFi認証のQRコードの形式が無効です 前に戻る バッテリー - %1$s ログ ホップ数 情報 @@ -275,8 +274,11 @@ 公開キーが一致しません 新しいノードの通知 SN比 + 信号対ノイズ比(SN比)は、通信において、目的の信号のレベルを背景ノイズのレベルに対して定量化するために使用される尺度です。Meshtasticや他の無線システムでは、SN比が高いほど信号が鮮明であることを示し、データ伝送の信頼性と品質を向上させることができます。 RSSI + 受信信号強度インジケーター(RSSI)は、アンテナで受信している電力レベルを測定するための指標です。一般的にRSSI値が高いほど、より強力で安定した接続を示します。 (屋内空気品質) 相対スケールIAQ値は、ボッシュBME680によって測定されます。 値の範囲は 0-500。 + ノードマップ 位置 管理 リモート管理 @@ -421,8 +423,6 @@ PAファン無効 MQTT を無視 MQTT設定 - 切断 - 接続済 MQTTを有効化 アドレス ユーザー名 @@ -651,6 +651,4 @@ トラフィック管理設定 モジュール有効 接続 - Meshtastic - 絞り込み diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml index 914446a60..0ba6232b9 100644 --- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml @@ -213,8 +213,11 @@ 공개 키가 일치하지 않습니다 새로운 노드 알림 SNR + 통신에서 원하는 신호의 수준을 배경 잡음의 수준과 비교하여 정량화하는 데 사용되는 신호 대 잡음비 Signal-to-Noise Ratio, SNR는 Meshtastic와 같은 무선 시스템에서 SNR이 높을수록 더 선명한 신호를 나타내어 데이터 전송의 안정성과 품질을 향상시킬 수 있습니다. RSSI + 수신 신호 강도 지표 Received Signal Strength Indicator, RSSI는 안테나가 수신하는 신호의 전력 수준을 측정하는 데 사용되는 지표입니다. RSSI 값이 높을수록 일반적으로 더 강력하고 안정적인 연결을 나타냅니다. (실내공기질) Bosch BME680으로 측정한 상대적 척도 IAQ 값. 범위 0–500. + 노드 지도 위치 최근 위치 업데이트 관리 @@ -370,8 +373,6 @@ PA fan 비활성화됨 MQTT로 부터 수신 무시 MQTT 설정 - 연결 끊김 - 연결됨 MQTT 활성화 서버 주소 사용자명 @@ -538,6 +539,4 @@ 파랑 초록 연결 - Meshtastic - 필터 diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml index 33f5e4d59..9592d8b14 100644 --- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml @@ -190,6 +190,7 @@ Naujo įtaiso pranešimas SNR RSSI + Įtaisų žemėlapis Administravimas Nuotolinis administravimas Silpnas @@ -216,7 +217,6 @@ Skambučio simbolis! Raudona Regionas - Atsijungta Viešasis raktas Privatus raktas Baigėsi laikas @@ -237,5 +237,4 @@ Raudona - Filtras diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml index b6972b6ec..ee07fb52b 100644 --- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml @@ -201,8 +201,11 @@ Publieke sleutel komt niet overeen Nieuwe node meldingen SNR + Signal-to-Noise Ratio, een meeting die wordt gebruikt in de communicatie om het niveau van een gewenst signaal tegenover achtergrondlawaai te kwantificeren. In Meshtastische en andere draadloze systemen geeft een hoger SNR een zuiverder signaal aan dat de betrouwbaarheid en kwaliteit van de gegevensoverdracht kan verbeteren. RSSI + Ontvangen Signal Sterkte Indicator, een meting gebruikt om het stroomniveau te bepalen dat de antenne ontvangt. Een hogere RSSI-waarde geeft een sterkere en stabielere verbinding aan. (Binnenluchtkwaliteit) relatieve schaal IAQ waarde gemeten door Bosch BME680. Waarde tussen 0 en 500. + Node Kaart Positie Beheer Extern beheer @@ -309,8 +312,6 @@ Inkomende negeren Negeer MQTT MQTT Configuratie - Niet verbonden - Verbonden MQTT ingeschakeld Adres Gebruikersnaam @@ -415,5 +416,4 @@ Blauw Groen Verbinding maken - Filter diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml index cd00c43e2..2ecd2a425 100644 --- a/core/resources/src/commonMain/composeResources/values-no/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml @@ -194,8 +194,11 @@ Direktemeldinger bruker den nye offentlige nøkkelinfrastrukturen for kryptering. Krever firmware versjon 2.5 eller høyere. Varsel om nye noder SNR + Signal-to-Noise Ratio, et mål som brukes i kommunikasjon for å sette nivået av et ønsket signal til bakgrunnstrøynivået. I Meshtastic og andre trådløse systemer tyder et høyere SNR på et klarere signal som kan forbedre påliteligheten og kvaliteten på dataoverføringen. RSSI + \"Received Signal Strength Indicator\", en måling som brukes til å bestemme strømnivået som mottas av antennen. Høyere RSSI verdi indikerer generelt en sterkere og mer stabil forbindelse. (Innendørs luftkvalitet) relativ skala IAQ-verdi målt ved Bosch BME680. Verdi 0–500. + Nodekart Administrasjon Fjernadministrasjon Dårlig @@ -219,7 +222,6 @@ Kopier Varsel, bjellekarakter! Region - Frakoblet Offentlig nøkkel Privat nøkkel Tidsavbrudd @@ -238,5 +240,4 @@ - Filter diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml index 7c9b3433b..448e7eaac 100644 --- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml @@ -223,7 +223,6 @@ Ciemny Domyślne ustawienie systemowe Wybierz motyw - Standardowy Podaj lokalizację telefonu do sieci Usunąć wiadomość? @@ -269,7 +268,6 @@ Zresetuj NodeDB Dostarczono Błąd - Nieznany błąd Zignoruj Usuń z listy ignorowanych Dodać '%1$s' do listy ignorowanych? @@ -333,9 +331,12 @@ Informacje o użytkowniku Powiadomienia o nowych węzłach SNR: + Współczynnik sygnału do szumu (Signal-to-Noise Ratio) - miara stosowana w komunikacji do określania poziomu pożądanego sygnału w stosunku do poziomu szumu tła. W Meshtastic i innych systemach bezprzewodowych wyższy współczynnik SNR oznacza czystszy sygnał, który może zwiększyć niezawodność i jakość transmisji danych. RSSI: + Received Signal Strength Indicator - miara używana do określenia poziomu mocy odbieranej przez antenę. Wyższa wartość RSSI zazwyczaj oznacza silniejsze i bardziej stabilne połączenie. Jakość powietrza w pomieszczeniach (Indoor Air Quality) - wartość względna w skali IAQ mierzona czujnikiem BME680. Zakres wartości: 0–500. Metryka urządzenia + Ślad na mapie Pozycjonowanie Ostatnia aktualizacja lokalizacji Metryki środowiskowe @@ -496,8 +497,6 @@ Zignoruj MQTT Ok dla MQTT Konfiguracja MQTT - Rozłączono - Połączony Włącz MQTT Adres Nazwa użytkownika @@ -748,6 +747,4 @@ Moduł Włączony Połącz Wykonano - Meshtastic - Filtr diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml index ac97b091c..7e753eefc 100644 --- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -230,8 +230,11 @@ Chave pública não confere Novas notificações de nó SNR + Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado para o nível de ruído de fundo. Na Meshtastic e outros sistemas sem fios, uma SNR maior indica um sinal mais claro que pode melhorar a confiabilidade e a qualidade da transmissão de dados. RSSI + Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de potência que está sendo recebida pela antena. Um valor maior de RSSI geralmente indica uma conexão mais forte e mais estável. (Qualidade do ar interior) valor relativo da escala IAQ medido pelo Bosch BME680. Intervalo de Valor de 0–500. + Mapa do nó Posição Atualização da última posição Administração @@ -379,8 +382,6 @@ Ventilador do PA desativado Ignorar MQTT Configurações MQTT - Desconectado - Conectado MQTT habilitado Endereço Nome de usuário @@ -664,6 +665,4 @@ Azul Verde Concluído - Meshtastic - Filtro diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml index a00bce554..0cc07b820 100644 --- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml @@ -219,8 +219,11 @@ Incompatibilidade de chave pública Notificações de novos nodes SNR + Relação sinal-para-ruído, uma medida utilizada nas comunicações para quantificar o nível de um sinal desejado com o nível de ruído de fundo. Em Meshtastic e outros sistemas sem fio. Quanto mais alta for a relação sinal-ruído, menor é o efeito do ruído de fundo sobre a deteção ou medição do sinal. RSSI + Indicador de Força de Sinal Recebido, uma medida usada para determinar o nível de energia que está a ser recebido pela antena. Um valor mais elevado de RSSI geralmente indica uma conexão mais forte e mais estável. (Qualidade do ar interior) valor relativo da escala IAQ conforme medida por Bosch BME680. Entre 0–500. + Mapa de nodes Posição Administração Administração Remota @@ -363,8 +366,6 @@ Ignorar entrada Ignorar MQTT Configuração MQTT - Desconectado - Ligado MQTT ativo Endereço Utilizador @@ -514,6 +515,4 @@ Azul Verde Ligar - Nome do nó de alternativo - Filtrar diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml index f9787ba93..e6ec807d8 100644 --- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml @@ -35,15 +35,11 @@ Ultima recepție via MQTT via MQTT - Intern după favorite Arată doar nodurile ignorate - Exclude MQTT Nerecunoscut În așteptarea confirmării În coadă pentru trimitere - Livrat la Mesh - Necunoscut Rutare prin lanțul SF++… Confirmat pe lanțul SF++ Confirmat @@ -93,9 +89,6 @@ Busola de pe ecran, în afara cercului, va indica întotdeauna nordul. Rotire ecran vertical. Unitățile afișate pe ecranul dispozitivului. - Suprascrie ecranul OLED automat. - Suprascrie aspectul implicit al ecranului. - Îngroşează textul din antet de pe ecran. Necesită ca dispozitivul dvs. să aibă un accelerometru. Regiunea în care veți folosi radioul. Presetările modemului disponibile, implicit este Long Fast (Rază lungă - rapid). @@ -117,10 +110,9 @@ Intervalul maxim care poate trece fără ca un nod să transmită o poziție. Cea mai rapidă actualizare a poziției care va fi trimisă dacă distanța minimă a fost respectată. Modificarea minimă a distanței în metri care trebuie luată în considerare pentru o transmisie inteligentă a poziției. - Cât de des ar trebui să încercăm să obținem o poziție GPS (<10sec păstrează GPS activat). + Cât de des ar trebui să se încerce obținerea locației GPS (<10 secunde menține GPS-ul activ). Câmpuri opționale care trebuie incluse la asamblarea mesajelor de poziție. Cu cât sunt incluse mai multe câmpuri, cu atât mesajul va fi mai mare, ceea ce va duce la un timp de transmisie mai lung și la un risc mai mare de pierdere a pachetelor. Va păstra totul în repaus cât mai mult posibil, pentru rolul de tracker și senzor, aceasta va include și radioul LoRa. Nu utilizați această setare dacă doriți să utilizați dispozitivul cu aplicațiile telefonului sau dacă utilizați un dispozitiv fără buton de utilizator. - Generată din cheia publică și trimisă către alte noduri din rețea pentru a le permite să calculeze o cheie secretă comună. Utilizat pentru a crea o cheie partajată cu un dispozitiv la distanță. Cheia publică autorizată să trimită mesaje de administrare către acest nod. Dispozitivul este gestionat de un administrator de rețea, utilizatorul neputând accesa niciuna dintre setările dispozitivului. @@ -159,7 +151,7 @@ Distribuie Nod nou găsit: %1$s Deconectat - Adormirea dispozitivului + Dispozitiv în sleep mode Adresa IP: Port: Conectat @@ -169,22 +161,14 @@ Conectare Neconectat Nici un dispozitiv selectat - Dispozitiv necunoscut - Nici un dispozitiv de rețea găsit - Niciun dispozitiv USB găsit - USB - Mod demonstrativ Connectat la dispozitivi, dar e în modul de sleep Aplicație prea veche Trebuie să updatezi această aplicație de pe Google Play (sau Github). Aplicația este prea veche pentru a comunica cu dispozitivul. Niciunul (dezactivat) Notificările serviciului Mulțumiri - Biblioteci open source - Meshtastic este construit cu următoarele biblioteci open source. Atingeți orice bibliotecă pentru a vedea licența. - Librării %1$d Acest URL de canal este invalid și nu poate fi folosit - Panou de depanare + Panou debug Date decodate: Export jurnale %1$d (de) jurnale exportate @@ -210,28 +194,14 @@ Ștergeți toate filtrele Adăugare filtru personalizat Presetări filtre - Salvează jurnalele din retea - Dezactivați pentru a omite scrierea jurnalelor din retea pe disc + Salvează jurnalele din mesh + Dezactivați pentru a omite scrierea jurnalelor din mesh pe disc Ștergeți jurnalele Potrivire oricare | toate Potrivire toate | oricare Aceasta va șterge toate pachetele de jurnal și intrările din baza de date de pe dispozitivul dvs. - Este o resetare completă și este permanentă. Șterge - Căutare emoji-uri... - Mai multe reacţii Canal - %1$s:%2$s - Mesaj de la %1$s %2$s - Antet - Obiect %1$d - Subsol - Casetă - Bulină - Text - Indicator - Degrade - Acesta este un element compozabil personalizat - Cu mai multe linii şi stiluri Status livrare mesaj Mesaje noi mai jos Notificări mesaje directe @@ -257,7 +227,6 @@ Setarea telefonului Alege tema Furnizați locația telefonului la mesh - Codare compactă pentru chirilică Ștergeți mesajul? Ștergeți %1$s mesaje? @@ -283,7 +252,7 @@ ⚠️ Aceasta va OPRI nodul. Pentru a-l repune în funcțiune, va fi necesară o intervenție fizică. Nod: %1$s Restartează - Trasare traseu + Traceroute Arată Introducere Mesaj Opțiuni chat rapid @@ -300,9 +269,7 @@ Mesaj direct Resetare NodeDB Livrare confirmată - Dispozitivul dumneavoastră se poate deconecta şi reporni în timp ce setările sunt aplicate. Eroare - Eroare necunoscuta Ignoră Eliminați din lista ignorate Adaugă '%1$s' in lista de ignor? Radioul tău va reporni după ce această modificare. @@ -343,8 +310,6 @@ În prezent: Mereu silențios Nu este silențios - Silențios pentru %1$d zile, %2$s ore - Silențios pentru %1$s ore Dezactivați notificările pentru „%1$s”? Activați notificările pentru '%1$s'? Înlocuire @@ -354,8 +319,6 @@ Baterie ChUtil AirUtil - %1$s - %1$s:%2$s Temp Hum Temp sol @@ -376,9 +339,12 @@ Info utilizator Notificări noduri noi SNR + Raportul semnal-zgomot (Signal-to-Noise Ratio), o măsură utilizată în comunicații pentru a cuantifica nivelul unui semnal dorit în raport cu nivelul zgomotului de fond. În Meshtastic și în alte sisteme wireless, un SNR mai mare indică un semnal mai clar, care poate îmbunătăți fiabilitatea și calitatea transmiterii datelor. RSSI + Indicatorul intensității semnalului recepționat (Received Signal Strength Indicator), o măsurătoare utilizată pentru a determina nivelul de putere recepționat de antenă. O valoare RSSI mai mare indică, în general, o conexiune mai puternică și mai stabilă. (Calitatea aerului interior) valoarea IAQ pe o scară relativă, măsurată cu Bosch BME680. Intervalul valorilor: 0–500. Valori dispozitiv + Harta nodurilor Poziție Ultima actualizare a poziției Indicatori de mediu @@ -408,21 +374,10 @@ Durată: %1$s s Ruta trasată spre destinație:\n\n Ruta urmărită înapoi la noi:\n\n - Redirecționare Hops - Hops de returnare - Dus-întors - Niciun raspuns - Încărcare 1m - Încărcare 5m - Încărcare 15m - Încărcătura medie a sistemului de un minut - Media de încărcare sistem de cinci minute 24H 1W 2W Maxim - Extindeți graficul - Restrânge graficul Vârstă necunoscută Copiere Caracter clopoțel de alertă! @@ -436,17 +391,11 @@ Canalul 1 Canalul 2 Canalul 3 - Canalul 4 - Canalul 5 - Canalul 6 - Canalul 7 - Canalul 8 Actual Tensiune Sunteți sigur? Documentația privind rolul dispozitivului și articolul de blog despre Alegerea rolului potrivit pentru dispozitiv.]]> Știu ce fac. - Nodul %1$s are bateria descărcată (%2$d%) Notificări pentru baterii descărcate Baterie descărcată: %1$s Notificări pentru baterii descărcate (noduri favorite) @@ -577,26 +526,12 @@ LoRa Opțiuni Avansate - Utilizare presetare Presetări Lățime bandă - Factor de răspândire - Rata de codificare Regiune - Numărul de Hops - Transmisie activată - Putere transmisie - Slot pentru frecvenţă - Suprascrie ciclul de obligații - Ignoră primirea - Amplificare RX amplificată - Suprascriere frecvență - Ventilator PA dezactivat Ignoră MQTT Acceptă MQTT Configurare MQTT - Deconectat - Conectat MQTT activat Adresă Nume de utilizator @@ -604,568 +539,54 @@ Criptare activată Ieșire JSON activată TLS activat - Temă rădăcină - Proxy-ul pentru client activat - Raportarea hărții - Intervalul de raportare hartă (secunde) - Configurare informații vecin - Info vecin activat - Interval de actualizare (secunde) - Transmite peste LoRA - Optiuni Wi-Fi Activat - WiFi activat - Numele rețelei - PSK - Opţiuni Ethernet - Ethernet activat - Server NTP - server rsyslog - Mod IPv4 - IP - Poartă de acces - Subred - DNS Configurație Paxcounter Paxcounter activat - Mesaj de stare: - Configurare mesaj prestabilit - Șirul de stare real - Pragul WiFi RSSI (implicit la -80) - Latitudine - Longitudine - Setează din locația curentă a telefonului - Mod GPS (hardware fizic) - Steaguri poziție - Configurare Putere - Activează modul de economisire a energiei - Închidere la pierderea de energie - Suprascriere multiplicator ADC - Raportul suprascrierii multiplicatorului ADC - Așteptați pentru durata Bluetooth - Durată maximă de somn - Durata minimă a trezirii - Adresa baterie INA_2XX I2C - Configurare test interval - Testul de gamă activat - Interval mesaj expeditor (secunde) - Salvați .CSV doar în memorie (ESP32) - Configurare hardware la distanță - Hardware extern activat - Permite acces Pin nedefinit - Pin-uri disponibile - Mesaj direct - Chei Admin - Chei publice - Cheia privată - Cheie Administrator - Mod Gestionat - Consolă serială - Debug log API activat - Canal implicit de administrator - Configurație serial - Serial activat - Echo activat - Rata baud-ului serial - RX - TX Expirat - Mod serial - Suprascrie portul serial al consolei - Puls - Numarul de inregistrari - istoric număr maxim de retur - Fereastra de returnare a istoricului - Server - Configurare telemetrie - Intervalul de actualizare a parametrilor dispozitivului - Interval actualizare valori mediu - Modul de măsurare mediu activat - Valorile de mediu pe ecran sunt activate - Valorile de mediu utilizează Fahrenheit - Interval actualizare măsurători de calitate a aerului - Pictograma calităţii aerului - Modul de măsurare putere activat - Interval actualizare măsurători de putere - Valori pe ecran activate - Configurare utilizator - ID-ul Nodului Nume lung Nume scurt Model hardware - Radioamator autorizat - Activarea acestei opțiuni dezactivează criptarea și nu este compatibilă cu rețeaua implicită de Meshtastic. Punct de rouă Presiune - Rezistența la gaz Distanță - Lux Vânt - Viteza vântului - Viteza rafale - Vânt critic - Directie vânt - Ploaie (1h) - Ploaie (24h) Greutate Radiație - Calitatea aerului interior (IAQ) URL - - Importă configurația - Exportă configurația - Dispozitive - Suportate - Număr modul - ID utilizator - Timp de functionare - Încărcare %1$d - Disc liber %1$d - Data si ora - Direcție - Viteza - %1$d Km/h - Sateliți - Alt - Frecvență - Slot - Primară - Poziție periodică și transmisiune telemetrică - Secundar - Nicio transmisiune periodică telemetrie - Solicitarea de poziție manuală este necesară - Apăsați și trageți pentru a reordona - Activare sunet - Dinamic - Împărtășește contacte - Notițe - Adaugă o notiță privată - Importați contactul partajat? - Netransmisibil - Nemonitorizată sau infrastructură - Cheie publică schimbată - Importa - Solicitare - Se solicită %1$s de la %2$s - Informații utilizator - Solicită telemetrie Valori dispozitiv Indicatori de mediu - Calitatea aerului, valoare Valori putere - Valori Gazdă - Valori Pax - Metadate - Acţiuni - Firmware - Utilizaţi formatul ceasului 12h - Când este activat, dispozitivul va afișa pe ecran ora în format 12 ore. - Valori Gazdă - Gazdă - Memorie Liberă - Încarcă - Șir Utilizator - Navigați în - Conexiune - Harta retea - Conversații - Noduri - Setări - Selectat - Setează-ți regiunea - Răspunde - Nodul tău va trimite periodic un pachet de rapoarte de hărți necriptate serverului MQTT configurat, acesta include un nume id, lung și scurt, aproximează locația, modelul hardware, rolul, versiunea firmware, regiunea LoRa, presetarea modemului și numele canalului primar. - Consimțământ pentru a Partaja date Node necriptate prin MQTT - Prin activarea acestei caracteristici, acceptați și consimți în mod expres transmiterea locației geografice în timp real a dispozitivului dvs. peste protocolul MQTT fără criptare. Aceste date de localizare pot fi utilizate în scopuri cum ar fi raportarea hărților live, urmărirea dispozitivelor și funcțiile telemetrice aferente. - Am citit şi înţeleg cele de mai sus. Sunt de acord voluntar cu transmiterea necriptată a datelor nodului prin MQTT - Sunt de acord - Actualizare firmware recomandată. - Pentru a beneficia de cele mai recente remedieri și caracteristici, vă rugăm să vă actualizați firmware-ul node.\n\nUltima versiune de firmware stabilă: %1$s - Expiră la - Timp - Dată - Filtru Hartă\n - Doar Favorite Arată repere - Arată cercuri de precizie - Notificare client - Verificare cheie - Solicitare de verificare cheie - Verificare cheie finalizată - Duplicat Cheie Publică detectată - Cheie Criptare slabă detectată - Chei promise detectate, selectaţi OK pentru regenerare. - Regenerează Cheia privată - Sunteţi sigur că doriţi să vă regeneraţi cheia privată?\n\nNodurile care ar fi putut schimba anterior chei cu acest modul vor trebui să elimine acel nod și să schimbe din nou tastele pentru a relua comunicarea securizată. - Modulele deblocate - Modulele sunt deja deblocate - De la distanta (%1$d online / %2$d afișate / %3$d în total) - Reacţionează - Deconectați - Derulare până jos Meshtastic - Stare de securitate - Securizare - Insigna de avertizare - Canal necunoscut. - Avertizare - Meniu de Overflow - LUX UV - Necunoscut - Acest radio este gestionat și poate fi schimbat doar de un administrator de la distanță. Avansate - Curăță baza de date a nodurilor - Curăță nodurile văzute ultima dată mai vechi de %1$d zile - Curăță doar noduri necunoscute - Curăţă acum - Acest lucru va elimina %1$d noduri din baza ta de date. Această acțiune nu poate fi anulată. - O blocare verde înseamnă că canalul este criptat în siguranță cu o cheie de 128 sau 256 bit AES. - Canalul nesigur, nu este exact - Blocare deschisă galbenă înseamnă că canalul nu este criptat în siguranţă, nu este utilizat pentru date precise privind localizarea și nu utilizează nicio cheie sau o cheie cunoscută de 1 octet. - Canal nesigur, precizie locație - Blocarea roşie înseamnă că canalul nu este criptat în siguranţă, se utilizează pentru date precise privind localizarea și nu se utilizează nici o cheie sau o cheie citată. - Atenție: Locație nesigură, precisă & MQTT Uplink - Un lacăt roșu deschis cu un avertisment înseamnă că canalul nu este criptat în siguranță este utilizat pentru date precise privind localizarea care sunt conectate la internet prin intermediul MQTT, şi nu foloseşte nici o cheie sau o cheie cunoscută. - Securitate canal - Mijloace de securitate canale - Afișați toate mijloacele - Arată statusul actual - Renunțați - Răspunde la %1$s - Anulați răspunsul - Ștergeți mesajul? - Șterge selecția Mesaj - Scrie un mesaj - Măsurători PAX - PAX - PAX: %1$d - B:%1$d - W:%1$d - PAX: %1$s - BLE: %1$s - WiFi: %1$s - Nu sunt disponibile măsurători PAX. - Wi-Fi Provisioning for mPWRD-OS - Dispozitive Bluetooth - Dispozitive conectate - Rata limită depășită. Te rugăm să încerci din nou mai târziu. - Descărcare - Instalate in acest moment - Ultimul stabil - Ultimul alfa - Sprijinită de comunitatea Meshtastic - Ediţie firmware - Dispozitive recente de rețea - Dispozitive ale rețelei descoperite - Dispozitive bluetooth disponibile - Să începem - Bine ai venit la - Rămâneţi conectat oriunde - Comunicați în afara grilei cu prietenii și comunitatea dvs. fără serviciu celular. - Creează-ţi propriile reţele - Înființarea cu ușurință a rețelelor private de plasare pentru comunicații sigure și fiabile în zonele îndepărtate; - Urmăriți și partajați locațiile - Partajați-vă locația în timp real și păstrați grupul coordonat cu funcții GPS integrate. - Notificări aplicații - Mesaje primite - Notificări pentru canal și mesaje directe. - Noduri Noi - Notificări pentru nodurile recent descoperite. - Baterie descarcata - Notificări pentru alerte de baterie scăzută pentru dispozitivul conectat. - Configurați permisiunile pentru notificări - Locaţia telefonului - Meshtastic folosește locația telefonului tău pentru a activa o serie de caracteristici. Poți actualiza permisiunile de locație în orice moment din setări. - Partajați locația - Folosește GPS-ul telefonului pentru a trimite locații către nodul tău în loc să folosești un GPS hardware de pe nodul tău. - Măsurătorile distanței - Afișează distanța dintre telefonul dvs. și alte noduri Meshtastice cu poziții. - Filtre distanță - Filtrează lista de noduri și harta plasei în funcție de proximitatea telefonului tău. - Locație hartă plasa - Activează punctul albastru pentru telefon în harta plasei. - Configurare permisiuni locație - Treci peste - setari - Alerte critice - Pentru a te asigura că primești alerte critice, cum ar fi mesajele - SOS, chiar și atunci când dispozitivul este în modul \"Nu deranja\" trebuie să acorzi permisiunea specială - . Vă rugăm să activați acest lucru în setările notificărilor. - - Configurează alertele critice - Meshtastic folosește notificări pentru a te ține la curent cu mesaje noi și alte evenimente importante. Puteți actualiza permisiunile de notificare în orice moment din setări. - Următor - %1$d noduri aflate în așteptare pentru ștergere: - Atenție: Acest lucru elimină nodurile din bazele de date in-app și on-device.\nSelecțiile sunt aditive. - Normal - Prin satelit - Teren - Hibridă - Gestionează Layers Hartă - Nu s-au încărcat straturi de hărți. - Ascunde Layer - Arată Layer - Elimină strat - Adăugați un strat - Noduri în această locație - Tipul hărții selectate - Gestionează surse personalizate de stil - Adaugă sursă de rețea Tile - Nu s-au găsit surse de comutare personalizată. - Modifică sursa rețelei - Ştergeţi sursa de reţea - Numele nu poate fi gol - Nume furnizor exista. - Adresa URL nu poate fi goală. - URL-ul trebuie să conţină substituenţi. - Şablon URL - punct de traseu - Aplicaţie - Versiune - Funcții canal - Partajarea locației - Pozitie periodica - Mesajele de la mesageria va fi trimise pe internet public prin intermediul oricărui portal configurat de nod. Mesajele provenite de la o un gateway public de internet sunt redirecționate către rețeaua locală. Datorită politicii de zero salturi, traficul provenit de la serverul MQTT implicit nu se va propaga mai departe de acest dispozitiv. - Semne pictograme - Dezactivarea poziției pe canalul primar permite transmisii periodice de poziție pe primul canal secundar cu poziția activată, altfel solicitarea manuală a poziției este necesară. - Configurare dispozitiv - "[Remote] %1$s" - Trimite telemetrie dispozitiv Activează/dezactivează modulul de telemetrie al dispozitivului pentru a trimite metrici către rețeaua mesh. Acestea sunt valori nominale. Rețelele mesh congestionate se vor scala automat la intervale mai lungi, în funcție de numărul de noduri online. - Oricare - 1 Oră + O oră 8 Ore 24 Ore 48 Ore - Filtrați după ultima oră: %1$s - %1$d dBm - Setări ale sistemului - Nici o statistică disponibilă - Analytics sunt colectate pentru a ne ajuta să îmbunătățim aplicația Android (mulțumesc), vom primi informații anonime despre comportamentul utilizatorului. Aceasta include rapoarte de accidente, ecrane folosite în aplicație, etc. - Platforme analitice: - Pentru mai multe informații, consultați politica noastră de confidențialitate. - Nesetat - 0 - %1$s de obicei este livrat cu un bootloader care nu acceptă actualizări OTA. Este posibil să fie nevoie să instalați un bootloader OTA prin USB înainte de a instala OTA. - Pentru RAK WisBlock RAK4631, folosiți unealta DFU serială's (de exemplu, seria adafruit-nrfutil dfu cu bootloader furnizat. fișier ip). Copierea fișierului .uf2 nu va actualiza bootloader-ul singur. - Don't arată din nou pe acest dispozitiv - Păstrează favoritele? - Actualizare firmware - Căutare actualizări... - Dispozitiv: %1$s - Instalat în prezent: %1$s - Actualizare către: %1$s - Stabil - Notă: Aceasta va deconecta temporar dispozitivul dumneavoastră în timpul actualizării. - Se descarcă firmware... %1$d% - Eroare: %1$s - Reîncercați - Actualizare reușită! - Gata - Se pornește DFU... - Se activează modul DFU... - Se validează firmware-ul... - Model hardware necunoscut: %1$d - Niciun dispozitiv conectat - Nu am putut găsi firmware-ul pentru %1$s în versiune - Extragere firmware... Actualizare eșuată - lucrăm la acest lucru... - Ţineţi dispozitivul aproape de telefon. - Nu închideți aplicația. - Aproape gata... - Acest lucru ar putea dura un minut... - Selectare fișier local - Fișier local - Sursa: Fișier Local - Lansare la distanţă necunoscută - Avertisment actualizare - Sunteți pe cale să instalați firmware-ul nou pe dispozitiv. Acest proces poartă riscuri.\n\n• Asigurați-vă că aparatul este încărcat.\n• Țineți dispozitivul aproape de telefon.\n• Nu închideți aplicația în timpul actualizării.\n\nVerificați că ați selectat firmware-ul corect pentru hardware-ul dumneavoastră. - Chirpy spune, \"Ţineţi-vă scara la îndemână!\" - Chirpy - Repornirea pe DFU... - High-cinci! Așteptați, copiere firmware-ul... - Vă rugăm să salvaţi fişierul .uf2 pe dispozitivul dvs's DFU unitate. - Atașare dispozitiv, vă rog așteptați... - Transfer fişier USB - BLE OTA - WiFi OTA - Updateaza către %1$s - Selectați DFU USB disk - Dispozitivul dvs. a repornit în modul DFU şi ar trebui să apară ca un dispozitiv USB (de exemplu, RAK4631).\n\nCând se deschide selectorul de fişiere, vă rugăm să selectaţi rădăcina acelui disc pentru a salva fişierul de firmwar. - Verific actualizarea... - Verificarea a expirat. Dispozitivul nu a reconectat în timp. - Se așteaptă ca dispozitivul să se reconecte... - Target: %1$s - Note de lansare - Eroare necunoscuta - Informațiile utilizatorului nodului lipsesc. - Baterie prea mică (%1$d%). Vă rugăm să încărcați dispozitivul înainte de actualizare. - Nu s-a putut recupera fișierul de firmware. - Actualizare USB nereuşită - Hash firmware respins. Dispozitivul poate avea nevoie de provizioane hash sau actualizare bootloader - Actualizare OTA esuata: %1$s - Se așteaptă ca dispozitivul să se repornească în modul OTA... - Conectarea la dispozitiv (încercarea %1$d/%2$d)... - Încărcare firmware... - Ştergere... - Înapoi Nesetat - Mereu pornit %1$d oră %1$d ore %1$d de ore - Busolă - Deschide busola - Distanță: %1$s - Bearing: %1$s - Acest dispozitiv nu are un senzor de busolă. Heading este indisponibil. - Este necesară permisiunea de localizare pentru a afișa distanța și rularea. - Furnizorul de localizare este dezactivat. Porniți serviciile de localizare - Se așteaptă o rezolvare GPS-ul pentru a calcula distanța și rularea. - Suprafață estimată: \u00b1%1$s (\u00b1%2$s) - Zonă estimată: precizie necunoscută - Marchează ca Citit - Acum - Următoarele canale au fost găsite în codul QR. Selectaţi o dată ce doriţi să adăugaţi pe dispozitivul dumneavoastră. Canalele existente vor fi păstrate. - Acest cod QR conţine o configuraţie completă. Aceasta va REPLACE canalele existente şi setările radio existente. Toate canalele existente vor fi eliminate. - Încărcare - Filtru mesaje - Activați filtrarea - Ascunde mesajele ce conțin cuvinte filtre - Filtrare cuvinte - Mesajele ce conţin aceste cuvinte vor fi ascunse - Adaugă cuvânt sau regex:pattern-ul - Nici un filtru cuvinte configurate - Model regex - Cuvânt întreg se potrivește - Arata %1$d filtrate - Ascunde %1$d filtrate - Filtrat - Activați filtrarea - Dezactivați filtrarea - Adresa canalului - Scanați NFC - Scanare contacte partajate NFC - Scanare cod QR contacte partajat - Introducere adresă contact partajată - Scanare canale NFC - Scanează canale cod QR - Introduceți URL-ul canalului - Distribuie codul QR al canalelor - Aduceți dispozitivul aproape de tag-ul NFC pentru a scana. - Generați codul QR - NFC este dezactivat. Vă rugăm să îl activați în setările de sistem. Toate Bluetooth - Configuraţi permisiunile Bluetooth - Descoperiți - Gestionați fără fir setările și canalele dispozitivului dvs. - Selecție stil hartă - Baterie: %1$d%% - Noduri: %1$d online / %2$d total - Actualizare: %1$s - ChUtil: %1$s% | AirTX: %2$s% - Trafic: TX %1$d / RX %2$d (D: %3$d) - Relee: %1$d (Canceled: %2$d) - Diagnosticuri: %1$s - Zgomotul %1$d dBm - Greșit %1$d - A pierdut %1$d - Titlu - %1$d / %2$d - %1$s - Alimentare - Reimprospatare - Actualizat - Adaugă nivel rețea - Fișier local MBTiles - Adaugă fișier MBTiles local - TAK (ATAK) - Configurare TAK - Activare server TAK local - Pornește un server TCP pe portul 8089 pentru conexiunile ATAK - Culoarea echipei - Rolul membrului - Nespecificat - Alb - Galben - Portocaliu - Mov Roșu - Maro - Violet - Albastru închis Albastru - Azuriu - Albastru-verzui Verde - Verde închis - Maro - Nespecificat - Membrii Echipei - Lider de echipă - Sediul Principal - Lunetist - Medic - Retrimite observatorul - Operator Radio Telefon - caine - Gestionare trafic - Modul activat - Deduplicare poziție - Precizie poziție (bits) - Interval poziţie minimă (sec) - NodeInfo Răspuns direct - Hops maxim pentru răspuns direct - Evaluare limitare - Evaluează fereastra limită (secunde) - Pachete Max în fereastră - Plasează pachete necunoscute - Prag de pachet necunoscut - Telemetrie doar local - Poziție doar-locală (raioane) - Păstrează Hops Router - Notiță - Dispozitiv de stocare & UI (doar cu permisiune) - Tema %1$s, Limba %2$s - Fișiere disponibile (%1$d): - - %1$s (%2$d bytes) - Nici un fişier manifestat. - Conectare - Gata - Wi-Fi Provisioning for mPWRD-OS - Furnizați acreditări Wi-Fi pentru dispozitivul mPWRD-OS prin Bluetooth. - Aflați mai multe despre proiectul mPWRD-OS\nhttps://github.com/mPWRD-OS - Căutare dispozitive - Dispozitiv gasit - Gata de scanare pentru rețele WiFi. - Scanare pentru reţele - Scanare… - Se aplică configurarea WiFi… - Nu au fost găsite rețele - Nu se poate conecta: %1$s - Nu s-a reușit scanarea pentru rețelele WiFi: %1$s - %1$d% - Rețele disponibile - Nume rețea (SSID) - Introdu sau selecteaza o retea - WiFi configurat cu succes! - Nu s-a reușit aplicarea configurației Wi-Fi - Meshtastic - Filtru diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml index 8d4590e82..a61d2e1dc 100644 --- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml @@ -257,15 +257,10 @@ Сброс значений по умолчанию Применить Тема - Контрастность Светлая Темная По умолчанию Выберите тему - Уровень контрастности - Стандартный - Средний - Высокий Предоставление местоположения для сети Компактная кодировка кириллицы @@ -313,7 +308,6 @@ Доставка подтверждена Ваше устройство может отключиться и перезагрузиться во время применения настроек. Ошибка - Неизвестная ошибка Игнорировать Удалить из игнорируемых Добавить '%1$s' в список игнорируемых? @@ -365,9 +359,9 @@ Батарея ChUtil AirUtil - %1$s: %2$s%% - %1$s: %2$s В - %1$s + %1$s: %2$.1f%% + %1$s: %2$.1f В + %1$.1f %1$s: %2$s Темп Влажн @@ -389,9 +383,12 @@ Пользовательская информация Уведомления о новых нодах Сигнал/шум + Соотношение сигнал/шум, мера, используемая в коммуникациях для количественной оценки уровня желаемого сигнала по отношению к уровню фонового шума. В Meshtastic и других беспроводных системах более высокий SNR указывает на более четкий сигнал, который может повысить надежность и качество передачи данных. RSSI + Индикатор уровня принимаемого сигнала, измерение, используемое для определения уровня мощности, принимаемой антенной. Более высокое значение RSSI обычно указывает на более сильное и стабильное соединение. (Качество воздуха в помещении) Относительная шкала IAQ, измеренная Bosch BME680. Диапазон значений 0–500. Интервал передачи + Карта нод Местоположение Обновление последнего местоположения Метрики окружения @@ -440,6 +437,7 @@ Макс Мин + Сред Развернуть диаграмму Свернуть диаграмму Неизвестный возраст @@ -591,9 +589,6 @@ Продолжительность вывода (миллисекунды) Таймаут Nag (в секундах) Рингтон - Импортировать рингтон - Файл пуст - Ошибка импорта: %1$s Воспроизвести Использовать I2S как буззер LoRa @@ -617,23 +612,6 @@ Игнорировать MQTT ОК в MQTT Настройка MQTT - Неактивно - Отключено - Отключено — %1$s - Подключение... - Подключено - Переподключение... - Переподключение (попытка %1$d) — %2$s - Проверить соединение - Проверяем брокер… - Доступно. Брокер принял учетные данные. - Доступно (%1$s) - Брокер отклонен: %1$s - Узел не найден - Не удается подключиться к брокеру (TCP) - Сбой TLS-рукопожатия - Тайм-аут после %1$d мс - Соединение не удалось MQTT включен Адрес Имя пользователя @@ -749,7 +727,6 @@ Дождь (24ч) Вес Радиация - Темп. 1-Wire Качество воздуха в помещении (IAQ) URL-адрес @@ -889,12 +866,6 @@ Написать сообщение Метрика прохожих PAX - PAX: %1$d - B:%1$d - W:%1$d - PAX: %1$s - BLE: %1$s - WiFi: %1$s Метрики прохожих недоступны Настройка Wi-Fi для mPWRD-OS Устройства Bluetooth @@ -1237,21 +1208,4 @@ Введите или выберите сеть Wi-Fi успешно настроен! Не удалось применить настройку Wi-Fi - Meshtastic Desktop - Показать Meshtastic - Выход - Meshtastic - Экспорт пакета данных TAK - Очистить часовой пояс - Фильтр - Удалить фильтр - Показать легенду качества воздуха - Показать статус сообщения - Отправить ответ - Скопировать сообщение - Выбрать сообщение - Удалить сообщение - Отреагировать эмодзи - Выберите устройство - Выбрать сеть diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml index 6beec1a74..257154144 100644 --- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml @@ -257,8 +257,11 @@ Nezhoda verejného kľúča Notifikácie nových uzlov SNR + Pomer signálu od šumu (SNR), miera používaná v komunikácii na kvantifikáciu úrovne požadovaného signálu k úrovni hluku pozadia. V Meshtastic a iných bezdrôtových systémoch znamená vyšší SNR jasnejší signál, ktorý môže zvýšiť spoľahlivosť a kvalitu prenosu údajov. RSSI + Indikátor sily prijímaného signálu (RSSI), meranie používané na určenie úrovne výkonu prijatého skrz anténu. Vyššia hodnota RSSI vo všeobecnosti znamená silnejšie a stabilnejšie pripojenie. (Kvalita vzduchu v interiéri) relatívna hodnota IAQ meraná prístrojom Bosch BME680. Rozsah hodnôt 0–500. + Mapa uzlov Pozícia Administrácia Administrácia na diaľku @@ -359,8 +362,6 @@ LoRa Šírka pásma Región - Odpojené - Pripojený Adresa Používateľské meno Heslo @@ -426,6 +427,4 @@ Červená Modrá Zelená - Meshtastic - Filter diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml index bff8e6150..8025c4751 100644 --- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml @@ -196,8 +196,11 @@ Neujemanje javnega ključa Obvestila novih vozlišč SNR + Razmerje med signalom in šumom je merilo, ki se uporablja v komunikacijah za količinsko opredelitev ravni želenega signala glede na raven hrupa v ozadju. V Meshtastic in drugih brezžičnih sistemih višji SNR pomeni jasnejši signal, ki lahko poveča zanesljivost in kakovost prenosa podatkov. RSSI + Indikator moči sprejetega signala je meritev, ki se uporablja za določanje ravni moči, ki jo sprejema antena. Višja vrednost RSSI na splošno pomeni močnejšo in stabilnejšo povezavo. (Kakovost zraka v zaprtih prostorih) relativna vrednost IAQ na lestvici, izmerjena z Bosch BME680. Razpon vrednosti 0–500. + Zemljevid vozlišč Administracija Administracija na daljavo Slab @@ -223,7 +226,6 @@ Kopiraj Znak opozorilnega zvonca! Regija - Prekinjeno Javni ključ Zasebni ključ Časovna omejitev @@ -242,5 +244,4 @@ - Filter diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml index edfac59b0..e70391f4d 100644 --- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml @@ -186,7 +186,10 @@ Kriptimi me Çelës Publik Përputhje e Gabuar e Çelësit Publik Njoftimet për nyje të reja + Raporti i Sinjalit në Zhurmë, një masë e përdorur në komunikime për të kuantifikuar nivelin e një sinjali të dëshiruar ndaj nivelit të zhurmës në background. Në Meshtastic dhe sisteme të tjera pa tel, një SNR më i lartë tregon një sinjal më të pastër që mund të rrisë besueshmërinë dhe cilësinë e transmetimit të të dhënave. + Indikatori i Fuqisë së Sinjalit të Marrë, një matje e përdorur për të përcaktuar nivelin e energjisë që po merret nga antena. Një vlerë më e lartë RSSI zakonisht tregon një lidhje më të fortë dhe më të qëndrueshme. (Cilësia e Ajrit të Brendshëm) shkalla relative e vlerës IAQ siç matet nga Bosch BME680. Intervali i Vlerave 0–500. + Harta e Nyjës Administratë Administratë e Largët I Keq @@ -199,7 +202,6 @@ Hops drejt %1$d Hops prapa %2$d 訊息 Rajon - I shkëputur Koha e skaduar Distanca @@ -216,5 +218,4 @@ - Filtrimi diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml index a365fc888..29b856819 100644 --- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml @@ -151,7 +151,6 @@ Тамна Прати систем Одабери тему - Стандардно Обезбедите локацију телефона меш мрежи Обриши поруку? @@ -241,9 +240,12 @@ Неусаглашеност јавних кључева Обавештење о новом чвору SNR + Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података. RSSI + Indikator jačine primljenog signala RSSI, merenje koje se koristi za određivanje nivoa snage koji antena prima. Viša vrednost RSSI generalno ukazuje na jaču i stabilniju vezu. (Kvalitet vazduha u zatvorenom prostoru) relativna skala vrednosti IAQ merena Bosch BME680. Raspon vrednosti 0–500. Метрика уређаја + Mapa čvorova Позиција Метрике сензора Administracija @@ -346,8 +348,6 @@ Игнориши MQTT Позитиван за MQTT MQTT подешавања - Raskačeno - Блутут повезан Адреса Корисничко име Лозинка @@ -429,5 +429,4 @@ Блутут Напајано - Filter diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml index 5bfbb0a84..13135d394 100644 --- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml @@ -151,7 +151,6 @@ Тамна Прати систем Одабери тему - Стандардно Обезбедите локацију телефона меш мрежи Обриши поруку? @@ -241,9 +240,12 @@ Неусаглашеност јавних кључева Обавештења о новим чворовима SNR + Однос сигнал/шум SNR је мера која се користи у комуникацијама за квантитативно одређивање нивоа жељеног сигнала у односу на ниво позадинског шума. У Мештастик и другим бежичним системима, већи SNR указује на јаснији сигнал који може побољшати поузданост и квалитет преноса података. RSSI + Индикатор јачине примљеног сигнала RSSI је мера која се користи за одређивање нивоа снаге која се прима преко антене. Виша вредност RSSI генерално указује на јачу и стабилнију везу. Индекс квалитета ваздуха (IAQ) као мера за одређивање квалитета ваздуха унутрашњости, мерен са Bosch BME680. Вредности се крећу у распону од 0 до 500. Метрика уређаја + Мапа чворова Позиција Метрике сензора Администрација @@ -346,8 +348,6 @@ Игнориши MQTT Позитиван за MQTT MQTT подешавања - Раскачено - Блутут повезан Адреса Корисничко име Лозинка @@ -429,5 +429,4 @@ Блутут Напајано - Филтер diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml index 59e19f1e5..da0bb8d4f 100644 --- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml @@ -43,7 +43,6 @@ Okänd Inväntar kvittens Kvittens köad - Levererad till nät Okänd Kvitterad Ingen rutt @@ -273,7 +272,6 @@ Nollställ NodeDB Sändning bekräftad Fel - Okänt fel Ignorera Ta bort från ignorerade Lägg till '%1$s' på ignorera-listan? Din radioenhet kommer att starta om efter denna ändring. @@ -340,9 +338,12 @@ Användarinfo Ny nod avisering SNR + Signal-to-Noise Ratio, är ett mått som används inom kommunikation för att kvantifiera nivån av en önskad signal mot nivån av bakgrundsbrus. I Meshtastic och andra trådlösa system indikerar en högre SNR en tydligare signal som kan förbättra tillförlitligheten och kvaliteten på dataöverföringen. RSSI + Received Signal Strength Indicator, ett mått som används för att avgöra effektnivån som togs emot av antennen. Ett högre RSSI-värde indikerar generellt en starkare och stabilare anslutning. (Indoor Air Quality) relativ skala IAQ värdet mätt med Bosch BME600. Värdeintervall 0-500. Enhetens mätvärden + Nod karta Plats Senaste positionsuppdatering Miljövärden @@ -371,7 +372,6 @@ Varaktighet: %1$s s Rutt spårad mot destination:\n\n Rutten spårad tillbaka till oss:\n\n - Inget svar 1h 24T 1V @@ -528,10 +528,6 @@ Ignorera MQTT Ok till MQTT MQTT-konfiguration - Frånkopplad - Ansluten - Testa anslutningen - Anslutningen misslyckades MQTT är aktiverat Adress Användarnamn @@ -948,7 +944,4 @@ Modul aktiverad Anslut Klart - Meshtastic - Filter - Välj enhet diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml index 75a9e3a5d..cbd1be2ae 100644 --- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml @@ -213,8 +213,11 @@ Genel Anahtar Uyuşmazlığı Yeni düğüm bildirimleri SNR + Sinyal-Gürültü Oranı, iletişimde istenen bir sinyalin seviyesini arka plan gürültüsü seviyesine mukayese ölçmek için kullanılan bir ölçüdür. Meshtastic ve diğer kablosuz sistemlerde, daha yüksek bir SNR, veri iletiminin güvenilirliğini ve kalitesini artırabilecek daha net bir sinyale işaret eder. RSSI + Alınan Sinyal Gücü Göstergesi, anten tarafından alınan güç seviyesini belirlemek için kullanılan bir ölçüdür. Daha yüksek bir RSSI değeri genellikle daha güçlü ve daha istikrarlı bir bağlantıya işaret eder. (İç Hava Kalitesi) Bosch BME680 tarafından ölçülen bağıl ölçekli IAQ değeri. Değer Aralığı 0–500. + Düğüm Haritası Konum Yönetim Uzaktan Yönetim @@ -363,8 +366,6 @@ PA fanı devre dışı MQTT'yi Yoksay MQTT Yapılandırması - Bağlantı kesildi - Bağlandı MQTT etkin Adres Kullanıcı adı @@ -545,6 +546,4 @@ Mavi Yeşil Bağlan - Meshtastic - Filtre diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml index c9a86af43..2c885d5e5 100644 --- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml @@ -222,7 +222,6 @@ Очищення бази вузлів Доставку підтверджено Помилка - Невідома помилка Ігнорувати Вилучити з ігнорованих Додати '%1$s' до чорного списку? Після цієї зміни ваш пристрій перезавантажиться. @@ -277,7 +276,9 @@ Сповіщення про нові вузли SNR RSSI + Показник рівня потужності сигналу — вимірювання, що використовується для визначення рівня потужності, що приймається антеною. Вище значення RSSI зазвичай вказує на міцніше та стабільніше з'єднання. Показники пристрою + Мапа вузлів Місцезнаходження Показники довкілля Адміністрування @@ -399,9 +400,6 @@ Перевизначити частоту Ігнорувати MQTT Налаштування MQTT - Відключено - Під’єднано - Перевірка зʼєднання MQTT увімкнений Адреса Ім'я користувача @@ -722,7 +720,4 @@ Зелений Під’єднатися Готово - Meshtastic - Фільтри - Оберіть пристрій diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml index 7fff0db20..f7c3d5e92 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -243,7 +243,6 @@ 深色 系统默认设置 选择主题 - 标准 向网格提供手机位置 紧凑的Cyrillic编码 @@ -290,7 +289,6 @@ 已送达 在应用设置时,您的设备可能会断开连接并重启。 错误 - 未知错误 忽略 从忽略中删除 添加 '%1$s' 到忽略列表? @@ -340,7 +338,9 @@ 电池 ChUtil AirUtil - %1$s + %1$s: %2$.1f%% + %1$s: %2$.1f V + %1$.1f %1$s: %2$s 温度 湿度 @@ -362,9 +362,12 @@ 用户信息 新节点通知 SNR + 信噪比(Signal-to-Noise Ratio, SNR)是一种用于通信领域的测量指标,用于量化目标信号与背景噪声的比例。在 Meshtastic 及其他无线系统中,较高的信噪比表示信号更加清晰,从而能够提升数据传输的可靠性和质量。 RSSI + 接收信号强度指示(Received Signal Strength Indicator, RSSI)是一种用于测量天线接收到的信号功率的指标。较高的 RSSI 值通常表示更强、更稳定的连接。 室内空气质量(Indoor Air Quality, IAQ):由 Bosch BME680 传感器测量的相对标尺 IAQ 值,取值范围为 0–500。 设备指标 + 节点地图 定位 最后位置更新 传感器指标 @@ -565,9 +568,6 @@ 忽略 MQTT 使用MQTT MQTT设置 - 已断开连接 - 已连接 - 连接测试 启用MQTT 地址 用户名 @@ -1114,7 +1114,4 @@ 备注 连接 完成 - Meshtastic - 搜索节点 - 选择设备 diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml index 20ee6c639..fb6856a0e 100644 --- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -18,7 +18,6 @@ Meshtastic - Meshtastic %1$s 過濾器 清除節點過濾器 篩選條件 @@ -41,11 +40,9 @@ 內部傳輸 通過喜好 僅顯示已忽略的節點 - 排除 MQTT 無法識別 正在等待確認 發送佇列中 - 已傳送至 Mesh 不明 透過 SF++ 鏈路由… 已在 SF++ 鏈上確認 @@ -123,7 +120,6 @@ 嘗試獲取 GPS 位置的頻率(< 10 秒將保持 GPS 模組開啟)。 位置訊息可選的附加欄位。包含的欄位越多,訊息越大,將造成空中時間拉長和封包遺失的風險增加。 將盡可能使所有元件進入睡眠狀態。對於 tracker 和 sensor 角色,此模式將包含 LoRa 無線電。如果您想搭配手機應用程式使用設備,或正在使用沒有使用者按鈕的設備,請勿啟用此設定。 - 從您的私鑰生成並傳送給網狀網路中的其他節點,以供它們計算出共享密鑰。 用於與遠端設備交換密鑰。 被授權可對此節點發送管理訊息的公鑰。 設備處於受管理狀態,使用者無法變更任何設備設定。 @@ -172,20 +168,12 @@ 正在連線 未連線 未選擇裝置 - 未知的裝置 - 找不到網路裝置 - 找不到 USB 裝置 - USB - 展示模式 已連接裝置,但該裝置正在休眠中 需要應用程式更新 您必須在應用商店(或 Github)更新此應用程式。它太舊無法與此無線電韌體通訊。請閱讀我們關於此主題的文件 無(停用) 服務通知 致謝 - 開放原始碼函式庫 - Meshtastic 採用以下開源函式庫建構而成。點擊任一函式庫以查看其授權條款。 - %1$d 函式庫 此頻道 URL 無效,無法使用 偵錯面板 解析封包: @@ -216,19 +204,7 @@ 符合全部條件 這將完全移除裝置上的所有日誌封包與資料庫記錄 - 這是一個完整的重設,且無法復原。 清除 - 搜尋表情符號…… - 更多符號 頻道 - %1$s: %2$s - 來自 %1$s 的訊息:%2$s - 標頭 - 標尾 - 點形 - 文字 - 儀表板 - 梯度 - 這是一個一個一個可客製化的組合元件 - 還支援多行文字與多種樣式 訊息傳遞狀態 下方有新的訊息 私訊通知 @@ -249,15 +225,10 @@ 恢復預設設置 套用 主題 - 對比度 淺色 深色 系統預設 選擇主題 - 對比度等級 - 標準 - 中等 - 將手機位置提供給Mesh網路 使用同形異意字元編碼處理西里爾字母 @@ -302,7 +273,6 @@ 已確認送達 在設定套用的過程中,您的裝置可能會斷開連線並重新啟動。 錯誤 - 未知錯誤 忽略 從忽略清單中移除 將 '%1$s' 加入忽略清單嗎? @@ -343,8 +313,6 @@ 目前: 永久靜音 未靜音 - 已靜音 %1$d 天 %2$s 小時 - 已靜音 %1$s 小時 將「%1$s」的通知設為靜音? 取消「%1$s」的通知靜音? 替換 @@ -354,10 +322,6 @@ 電池 頻道利用率 空中時間使用率 - %1$s:%2$s%% - %1$s:%2$s%V - %1$s - %1$s:%2$s 溫度 濕度 土壤溫度 @@ -378,9 +342,12 @@ 使用者資訊 新節點通知 SNR + 信噪比(SNR),用於通訊中量化所需信號與背景噪音水平的指標。在 Meshtastic 及其他無線系統中,信噪比越高表示信號越清晰,可以提高數據傳輸的可靠性和品質。 RSSI + 接收信號強度指示(RSSI)用於測量天線所接收到信號的功率強度。 RSSI 值越高通常代表連線越強且穩定。 (室內空氣品質) 相對尺度 IAQ 值,由 Bosch BME680 測量。值範圍 0–500。 裝置計量資料 + 節點地圖 位置 最後位置更新 環境計量資料 @@ -408,26 +375,12 @@ 持續時間:%1$s 秒 追蹤至目的地的路由:\n\n 追蹤回到本機的路由:\n\n - 去程跳數 - 回程跳數 - 來回跳數 - 無回應 - 1分鐘負載 - 5分鐘負載 - 15分鐘負載 - 1分鐘系統負載平均值 - 5分鐘系統負載平均值 - 15分鐘系統負載平均值 - 可用系統記憶體(位元組) 1小時 二十四小時 一週 二週 1個月 最大值 - 最小 - 展開圖表 - 收起圖表 未知年齡 複製 警鈴字符! @@ -441,17 +394,11 @@ 頻道1 頻道2 頻道3 - 頻道 4 - 頻道 5 - 頻道 6 - 頻道 7 - 頻道 8 當前 電壓 你確定嗎? 設備角色檔案和關於選擇正確的設備角色的博客文章 。]]> 我知道我在做什麼。 - 節點 %1$s 電量過低 (%2$d%) 低電量通知 低電量:%1$s 低電量通知(收藏節點) @@ -577,9 +524,6 @@ 輸出持續時間(毫秒) 通知逾時時間(秒) 鈴聲 - 已匯入鈴聲 - 檔案為空 - 匯入錯誤:%1$s 播放 使用 I2S 控制蜂鳴器 LoRa @@ -603,23 +547,6 @@ 無視MQTT 允許轉發至 MQTT MQTT配置 - 已停用 - 已中斷連線 - 已斷線 — %1$s - 正在連接… - 已連線 - 重新連接中… - 重新連接中(第 %1$d 次嘗試) — %2$s - 測試連線 - 正在查詢 Broker… - 可供連線,Broker 已驗證並接受憑證。 - 可供連線(%1$s) - Broker 遭拒:%1$s - 找不到伺服器 - 無法連線至 Broker 中繼伺服器(TCP) - TLS 握手失敗 - 經過 %1$d 毫秒後逾時 - 測試失敗 啟用MQTT服務器 地址 用戶名 @@ -691,8 +618,6 @@ 啟用序列埠 啟用 Echo 序列埠鮑率 - RX - TX 逾時 序列埠模式 覆蓋控制台序列埠 @@ -727,15 +652,8 @@ 距離 照度 風速 - 風速 - 陣風 - 風停 - 風向 - 降雨(1h) - 降雨(24h) 重量 輻射 - 1-Wire 溫度 室內空氣品質 (IAQ) 網址 @@ -752,7 +670,6 @@ 時間戳記 航向 速度 - %1$d Km/h 衛星數 海拔 頻率 @@ -818,11 +735,6 @@ 顯示路徑 顯示定位精準度 客户端通知 - 金鑰驗證 - 金鑰驗證請求 - 金鑰驗證已完成 - 偵測到重複的公鑰 - 偵測到加密金鑰強度不足 偵測到金鑰已洩漏,點選確定後重新產生金鑰。 重新產生私鑰 您確定要重新產生密鑰嗎?\n\n連線過的其他節點需要刪除並重新交換金鑰後才能恢復加密通訊連線。 @@ -875,14 +787,7 @@ 請輸入訊息 PAX 人流計量 PAX - PAX: %1$d - B:%1$d - W:%1$d - PAX: %1$s - BLE: %1$s - WiFi: %1$s 無可用的 PAX 人流計量資料。 - mPWRD-OS 的 Wi-Fi 設定 藍牙裝置 連接裝置 超過速率限制,請稍後再嘗試。 @@ -1003,7 +908,6 @@ 穩定版 Alpha 測試版 注意:更新期間將會暫時中斷您的裝置連線。 - 正在下載韌體... %1$d% 錯誤: %1$s 重試 更新成功! @@ -1046,7 +950,6 @@ 版本說明 未知錯誤 缺少節點使用者資訊。 - 電量過低 (%1$d%%),請在更新前為您的裝置充電。 無法取得韌體檔案。 USB 更新失敗 韌體雜湊值遭拒。裝置可能需要雜湊值配置或開機載入程式更新。 @@ -1120,10 +1023,8 @@ 設定 無線管理你的裝置設定與頻道。 地圖樣式選擇 - 電量:%1$d% 線上 %1$d / 總計 %2$d 上線時間: %1$s - 頻道使用率: %1$s% | 空中傳輸佔用率: %2$s% 流量: 傳送 %1$d / 接收 %2$d (丟棄: %3$d) 中繼: %1$d (取消: %2$d) 診斷: %1$s @@ -1142,8 +1043,6 @@ 新增本機 MBTiles 檔案 TAK (ATAK) TAK 設定 - 啓用本地 TAK 伺服器 - 在 8089 埠啟動一個用於 ATAK 連線的 TCP 伺服器 隊伍顏色 隊員角色 未指定 @@ -1187,46 +1086,6 @@ 僅本地定位資訊(中繼) 保留路由跳數 注意 - 裝置儲存空間與使用者介面(唯讀) - 主題 %1$s,語言 %2$s - 可使用檔案(%1$d): - - %1$s(%2$d 位元) - 未發現任何檔案。 連線 完成 - mPWRD-OS 的 Wi-Fi 設定 - 透過藍牙為您的 mPWRD-OS 裝置設定 Wi-Fi 憑證。 - 進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS - 正在搜尋裝置… - 找到裝置 - 準備好掃描 Wi-Fi 網路了。 - 搜尋網路 - 正在搜尋… - 正在套用 Wi-Fi 設定… - 找不到網路 - 無法連接:%1$s - 無法搜尋到 Wi-Fi 網路:%1$s - %1$d% - 可用的網路 - 網路名稱(SSID) - 手動輸入或選擇一個網路 - Wi-Fi 已設定完成! - 無法套用 Wi-Fi 設定 - Meshtastic Desktop - 顯示 Meshtastic - 離開 - Meshtastic - 匯出 TAK 資料封包 - 清除時區 - 過濾器 - 移除篩選條件 - 顯示空氣品質圖例 - 顯示訊息狀態 - 傳送回覆 - 複製訊息 - 選擇訊息 - 刪除訊息 - 使用表情符號回應 - 選擇裝置 - 選擇網路 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 505d80821..5d7eba25a 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -278,15 +278,10 @@ Reset to defaults Apply Theme - Contrast Light Dark System default Choose theme - Contrast level - Standard - Medium - High Provide phone location to mesh Compact encoding for Cyrillic @@ -332,7 +327,6 @@ Delivery confirmed Your device may disconnect and reboot while settings are applied. Error - Unknown error Ignore Remove from ignored Add '%1$s' to ignore list? @@ -384,9 +378,9 @@ Battery ChUtil AirUtil - %1$s: %2$s%% - %1$s: %2$s V - %1$s + %1$s: %2$.1f%% + %1$s: %2$.1f V + %1$.1f %1$s: %2$s Temp Hum @@ -408,9 +402,12 @@ User Info New node notifications SNR + Signal-to-Noise Ratio, a measure used in communications to quantify the level of a desired signal to the level of background noise. In Meshtastic and other wireless systems, a higher SNR indicates a clearer signal that can enhance the reliability and quality of data transmission. RSSI + Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection. (Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500. Device Metrics + Node Map Position Last position update Environment Metrics @@ -457,6 +454,7 @@ 1M Max Min + Avg Expand chart Collapse chart Unknown Age @@ -608,9 +606,6 @@ Output duration (milliseconds) Nag timeout (seconds) Ringtone - Imported ringtone - File is empty - Error importing: %1$s Play Use I2S as buzzer LoRa @@ -634,23 +629,6 @@ Ignore MQTT Ok to MQTT MQTT Config - Inactive - Disconnected - Disconnected — %1$s - Connecting… - Connected - Reconnecting… - Reconnecting (attempt %1$d) — %2$s - Test connection - Probing broker… - Reachable. Broker accepted credentials. - Reachable (%1$s) - Broker rejected: %1$s - Host not found - Cannot reach broker (TCP) - TLS handshake failed - Timed out after %1$d ms - Connection failed MQTT enabled Address Username @@ -766,7 +744,6 @@ Rain (24h) Weight Radiation - 1-Wire Temp Indoor Air Quality (IAQ) URL @@ -1274,22 +1251,4 @@ Enter or select a network WiFi configured successfully! Failed to apply WiFi configuration - Meshtastic Desktop - Show Meshtastic - Quit - Meshtastic - Export TAK Data Package - mPWRD-OS - Clear time zone - Filter - Remove filter - Show air quality legend - Show message status - Send reply - Copy message - Select message - Delete message - React with emoji - Select device - Select network diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt index 8b939fa9b..91eb97484 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidFileServiceTest.kt @@ -16,11 +16,9 @@ */ package org.meshtastic.core.service -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith -import org.meshtastic.core.di.CoroutineDispatchers import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @@ -29,15 +27,10 @@ import kotlin.test.assertNotNull @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class AndroidFileServiceTest { - private val testDispatchers = - UnconfinedTestDispatcher().let { dispatcher -> - CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher) - } - @Test fun testInitialization() = runTest { val context = RuntimeEnvironment.getApplication() - val service = AndroidFileService(context, testDispatchers) + val service = AndroidFileService(context) assertNotNull(service) } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt index 8924cdcc8..010fcdc89 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidFileService.kt @@ -18,7 +18,7 @@ package org.meshtastic.core.service import android.app.Application import co.touchlab.kermit.Logger -import com.eygraber.uri.toAndroidUri +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okio.BufferedSink import okio.BufferedSource @@ -26,16 +26,15 @@ import okio.buffer import okio.sink import okio.source import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.toAndroidUri import org.meshtastic.core.repository.FileService import java.io.FileOutputStream @Single -class AndroidFileService(private val context: Application, private val dispatchers: CoroutineDispatchers) : - FileService { - override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean = - withContext(dispatchers.io) { +class AndroidFileService(private val context: Application) : FileService { + override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean = + withContext(Dispatchers.IO) { try { val pfd = context.contentResolver.openFileDescriptor(uri.toAndroidUri(), "wt") if (pfd == null) { @@ -52,8 +51,8 @@ class AndroidFileService(private val context: Application, private val dispatche } } - override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean = - withContext(dispatchers.io) { + override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean = + withContext(Dispatchers.IO) { try { val success = context.contentResolver.openInputStream(uri.toAndroidUri())?.use { inputStream -> diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index af7cb85c2..c7ef0ed10 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -17,22 +17,15 @@ package org.meshtastic.core.service import android.content.Context -import android.content.Intent import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.StateFlow import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.SharedContact -import org.meshtastic.proto.User /** * Android [RadioController] implementation that delegates to the bound [MeshService] via AIDL. @@ -48,7 +41,6 @@ class AndroidRadioControllerImpl( private val nodeRepository: NodeRepository, ) : RadioController { - /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ override val connectionState: StateFlow get() = serviceRepository.connectionState @@ -76,37 +68,41 @@ class AndroidRadioControllerImpl( override suspend fun sendSharedContact(nodeNum: Int): Boolean { val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) val contact = - SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified) + org.meshtastic.proto.SharedContact( + node_num = nodeDef.num, + user = nodeDef.user, + manually_verified = nodeDef.manuallyVerified, + ) val action = ServiceAction.SendContact(contact) serviceRepository.onServiceAction(action) return action.result.await() } - override suspend fun setLocalConfig(config: Config) { + override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) { serviceRepository.meshService?.setConfig(config.encode()) } - override suspend fun setLocalChannel(channel: Channel) { + override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) { serviceRepository.meshService?.setChannel(channel.encode()) } - override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { + override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) { serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode()) } - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { + override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) { serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode()) } - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { + override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) { serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode()) } - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { + override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) { serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode()) } - override suspend fun setFixedPosition(destNum: Int, position: Position) { + override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) { serviceRepository.meshService?.setFixedPosition(destNum, position) } @@ -174,7 +170,7 @@ class AndroidRadioControllerImpl( serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) } - override suspend fun requestPosition(destNum: Int, currentPosition: Position) { + override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) { serviceRepository.meshService?.requestPosition(destNum, currentPosition) } @@ -217,7 +213,10 @@ class AndroidRadioControllerImpl( @Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder serviceRepository.meshService?.setDeviceAddress(address) // Ensure service is running/restarted to handle the new address - val intent = Intent().apply { setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") } + val intent = + android.content.Intent().apply { + setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") + } context.startForegroundService(intent) } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt index 36c26c879..966569f4f 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt @@ -20,12 +20,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.PacketRepository @@ -38,9 +38,7 @@ class MarkAsReadReceiver : private val serviceNotifications: MeshServiceNotifications by inject() - private val dispatchers: CoroutineDispatchers by inject() - - private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) companion object { const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ" diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 5869ce94f..701ca2d69 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -25,11 +25,11 @@ import android.os.IBinder import androidx.core.app.ServiceCompat import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.koin.android.ext.android.inject import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.common.util.toRemoteExceptions -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.MeshUser @@ -42,7 +42,6 @@ import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.SERVICE_NOTIFY_ID @@ -50,12 +49,6 @@ import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.PortNum -/** - * Android foreground service that hosts the Meshtastic mesh radio connection. - * - * Acts as the lifecycle anchor for the [MeshServiceOrchestrator], which manages all manager initialization and - * connection state. Exposes an AIDL binder for external client integration via [core:api]. - */ // IMeshService is deprecated but still required for AIDL binding @Suppress("TooManyFunctions", "LargeClass", "DEPRECATION") class MeshService : Service() { @@ -74,20 +67,12 @@ class MeshService : Service() { private val connectionManager: MeshConnectionManager by inject() - private val notifications: MeshServiceNotifications by inject() - - /** Android-typed accessor for the foreground service notification. */ - private val androidNotifications: MeshServiceNotificationsImpl - get() = notifications as MeshServiceNotificationsImpl - private val orchestrator: MeshServiceOrchestrator by inject() private val router: MeshRouter by inject() - private val dispatchers: CoroutineDispatchers by inject() - private val serviceJob = Job() - private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) } + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private var isServiceInitialized = false @@ -145,8 +130,7 @@ class MeshService : Service() { val a = radioInterfaceService.getDeviceAddress() val wantForeground = a != null && a != "n" - connectionManager.updateStatusNotification() - val notification = androidNotifications.getServiceNotification() + val notification = connectionManager.updateStatusNotification() as android.app.Notification val foregroundServiceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index 211e3b9c4..05fe1d3b4 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -41,7 +41,6 @@ import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node @@ -267,8 +266,7 @@ class MeshServiceNotificationsImpl( enableLights(true) enableVibration(true) setBypassDnd(true) - val alertSoundUri = - "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.meshtastic_alert}".toUri() + val alertSoundUri = "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.alert}".toUri() setSound( alertSoundUri, AudioAttributes.Builder() @@ -290,29 +288,20 @@ class MeshServiceNotificationsImpl( private var cachedLocalStats: LocalStats? = null private var nextStatsUpdateMillis: Long = 0 private var cachedMessage: String? = null - private var cachedServiceNotification: Notification? = null - - /** - * Returns the last-built service state notification, or builds a default one if none exists. This is used by - * [MeshService] for [android.app.Service.startForeground]. - */ - fun getServiceNotification(): Notification = cachedServiceNotification - ?: createServiceStateNotification( - name = getString(Res.string.meshtastic_app_name), - message = null, - nextUpdateAt = 0, - ) // region Public Notification Methods @Suppress("CyclomaticComplexMethod", "NestedBlockDepth") - override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) { + override fun updateServiceStateNotification( + state: org.meshtastic.core.model.ConnectionState, + telemetry: Telemetry?, + ): Notification { val summaryString = when (state) { - is ConnectionState.Connected -> + is org.meshtastic.core.model.ConnectionState.Connected -> getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected) - is ConnectionState.Disconnected -> getString(Res.string.disconnected) - is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) - is ConnectionState.Connecting -> getString(Res.string.connecting) + is org.meshtastic.core.model.ConnectionState.Disconnected -> getString(Res.string.disconnected) + is org.meshtastic.core.model.ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) + is org.meshtastic.core.model.ConnectionState.Connecting -> getString(Res.string.connecting) } // Update caches if telemetry is provided @@ -368,8 +357,8 @@ class MeshServiceNotificationsImpl( message = cachedMessage, nextUpdateAt = nextStatsUpdateMillis, ) - cachedServiceNotification = notification notificationManager.notify(SERVICE_NOTIFY_ID, notification) + return notification } override suspend fun updateMessageNotification( diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt index f4db74403..5965b9ddd 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt @@ -21,11 +21,11 @@ import android.content.Context import android.content.Intent import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.ServiceRepository @@ -41,9 +41,7 @@ class ReactionReceiver : private val serviceRepository: ServiceRepository by inject() - private val dispatchers: CoroutineDispatchers by inject() - - private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @Suppress("TooGenericExceptionCaught", "ReturnCount") override fun onReceive(context: Context, intent: Intent) { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt index d7a943783..4e82a735d 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt @@ -21,11 +21,11 @@ import android.content.Context import android.content.Intent import androidx.core.app.RemoteInput import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MeshServiceNotifications @@ -44,9 +44,7 @@ class ReplyReceiver : private val meshServiceNotifications: MeshServiceNotifications by inject() - private val dispatchers: CoroutineDispatchers by inject() - - private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) companion object { const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION" diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt index 22bacf43a..57408cff1 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt @@ -133,7 +133,7 @@ class ServiceBroadcasts(private val context: Context, private val serviceReposit explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED)) } - // Restore legacy action for other consumers (e.g. ATAK plugins) + // Restore legacy action for other consumers (e.g. mesh_service_example) val legacyIntent = Intent(ACTION_CONNECTION_CHANGED).apply { putExtra(EXTRA_CONNECTED, stateStr) diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt index a753d2d08..fce0438dd 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -63,7 +63,6 @@ class DirectRadioControllerImpl( private val myNodeNum: Int get() = nodeManager.myNodeNum.value ?: 0 - /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ override val connectionState: StateFlow get() = serviceRepository.connectionState diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index ebac9f71b..7e9832b54 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -19,20 +19,19 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -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.CommandSender import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs @@ -52,30 +51,35 @@ import org.meshtastic.core.takserver.TAKServerManager class MeshServiceOrchestrator( private val radioInterfaceService: RadioInterfaceService, private val serviceRepository: ServiceRepository, + private val packetHandler: PacketHandler, private val nodeManager: NodeManager, private val messageProcessor: MeshMessageProcessor, + private val commandSender: CommandSender, + private val connectionManager: MeshConnectionManager, private val router: MeshRouter, private val serviceNotifications: MeshServiceNotifications, private val takServerManager: TAKServerManager, private val takMeshIntegration: TAKMeshIntegration, private val takPrefs: TakPrefs, + private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers, private val databaseManager: DatabaseManager, - private val connectionManager: MeshConnectionManager, - private val dispatchers: CoroutineDispatchers, ) { - // 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 + private var serviceJob: Job? = null + private var takJob: Job? = null + + /** The coroutine scope for the service. Available after [start] is called. */ + var serviceScope: CoroutineScope? = null + private set /** Whether the orchestrator is currently running. */ val isRunning: Boolean - get() = scope?.isActive == true + get() = serviceJob?.isActive == true /** * Starts the mesh service components and wires up data flows. * - * This is the KMP equivalent of `MeshService.onCreate()`. It connects to the radio and wires incoming radio data to - * the message processor and service actions to the router's action handler. + * This is the KMP equivalent of `MeshService.onCreate()`. It starts all managers, connects to the radio, and wires + * incoming radio data to the message processor and service actions to the router's action handler. */ fun start() { if (isRunning) { @@ -84,31 +88,35 @@ class MeshServiceOrchestrator( } Logger.i { "Starting mesh service orchestrator" } - 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() + val job = Job() + serviceJob = job + val scope = CoroutineScope(dispatchers.default + job) + serviceScope = scope serviceNotifications.initChannels() - connectionManager.updateStatusNotification() + + packetHandler.start(scope) + router.start(scope) + nodeManager.start(scope) + connectionManager.start(scope) + messageProcessor.start(scope) + commandSender.start(scope) // Observe TAK server pref to start/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() + 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() + } } - } - .launchIn(newScope) + .launchIn(scope) - newScope.handledLaunch { + scope.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 @@ -122,18 +130,18 @@ class MeshServiceOrchestrator( radioInterfaceService.receivedData .onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum.value) } - .launchIn(newScope) + .launchIn(scope) radioInterfaceService.connectionError .onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) } - .launchIn(newScope) + .launchIn(scope) // 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 -> newScope.handledLaunch { router.actionHandler.onServiceAction(action) } } - .launchIn(newScope) + .onEach { action -> scope.handledLaunch { router.actionHandler.onServiceAction(action) } } + .launchIn(scope) nodeManager.loadCachedNodeDB() } @@ -145,11 +153,14 @@ 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() } - scope?.cancel() - scope = null + serviceJob?.cancel() + serviceJob = null + serviceScope = null } } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt index 8671188ef..ad5b92bd5 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt @@ -42,7 +42,7 @@ import org.meshtastic.proto.MeshPacket @Suppress("TooManyFunctions") open class ServiceRepositoryImpl : ServiceRepository { - // Canonical app-level connection state — written exclusively by MeshConnectionManager. + // Connection state to our radio device private val _connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow get() = _connectionState diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 1bb63971c..0785624f5 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -19,15 +19,10 @@ package org.meshtastic.core.service import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import co.touchlab.kermit.Logger -import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope -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 @@ -37,7 +32,6 @@ 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 @@ -45,7 +39,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.ignoreExceptionSuspend +import org.meshtastic.core.common.util.ignoreException import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState @@ -83,31 +77,20 @@ class SharedRadioInterfaceService( override val supportedDeviceTypes: List get() = transportFactory.supportedDeviceTypes - /** - * Transport-level connection state reflecting the raw hardware link status. - * - * Updated directly by [onConnect] and [onDisconnect] when the physical transport (BLE, TCP, Serial) connects or - * disconnects. This is consumed exclusively by - * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager], which reconciles it into the - * canonical app-level - * [ServiceRepository.connectionState][org.meshtastic.core.repository.ServiceRepository.connectionState]. - */ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow = _connectionState.asStateFlow() private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value) override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() - // Unbounded Channel preserves strict FIFO delivery of incoming radio bytes, which the - // firmware handshake depends on (initial config packet ordering). A SharedFlow with - // `launch { emit() }` per packet reorders under concurrent dispatch and breaks config load. - // trySend on an UNLIMITED channel never suspends and never drops, so handleFromRadio can - // remain a non-suspend synchronous callback. - private val _receivedData = Channel(Channel.UNLIMITED) - override val receivedData: Flow = _receivedData.receiveAsFlow() + private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64) + override val receivedData: SharedFlow = _receivedData private val _meshActivity = - MutableSharedFlow(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) + MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, + ) override val meshActivity: SharedFlow = _meshActivity.asSharedFlow() private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64) @@ -117,12 +100,12 @@ class SharedRadioInterfaceService( get() = _serviceScope private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - private var radioTransport: RadioTransport? = null - private var runningTransportId: InterfaceId? = null + private var radioIf: RadioTransport? = null + private var runningInterfaceId: InterfaceId? = null private var isStarted = false - private val listenersInitialized = atomic(false) - private var heartbeatJob: Job? = null + private val listenersInitialized = kotlinx.atomicfu.atomic(false) + private var heartbeatJob: kotlinx.coroutines.Job? = null private var lastHeartbeatMillis = 0L @Volatile private var lastDataReceivedMillis = 0L @@ -138,7 +121,7 @@ class SharedRadioInterfaceService( } private val initLock = Mutex() - private val transportMutex = Mutex() + private val interfaceMutex = Mutex() private fun initStateListeners() { if (listenersInitialized.value) return @@ -149,23 +132,22 @@ class SharedRadioInterfaceService( radioPrefs.devAddr .onEach { addr -> - transportMutex.withLock { + interfaceMutex.withLock { if (_currentDeviceAddressFlow.value != addr) { _currentDeviceAddressFlow.value = addr - startTransportLocked() + startInterfaceLocked() } } } - .catch { Logger.e(it) { "devAddr flow crashed" } } .launchIn(processLifecycle.coroutineScope) bluetoothRepository.state .onEach { state -> - transportMutex.withLock { + interfaceMutex.withLock { if (state.enabled) { - startTransportLocked() - } else if (runningTransportId == InterfaceId.BLUETOOTH) { - stopTransportLocked() + startInterfaceLocked() + } else if (runningInterfaceId == InterfaceId.BLUETOOTH) { + stopInterfaceLocked() } } } @@ -174,11 +156,11 @@ class SharedRadioInterfaceService( networkRepository.networkAvailable .onEach { state -> - transportMutex.withLock { + interfaceMutex.withLock { if (state) { - startTransportLocked() - } else if (runningTransportId == InterfaceId.TCP) { - stopTransportLocked() + startInterfaceLocked() + } else if (runningInterfaceId == InterfaceId.TCP) { + stopInterfaceLocked() } } } @@ -189,11 +171,11 @@ class SharedRadioInterfaceService( } override fun connect() { - processLifecycle.coroutineScope.launch { transportMutex.withLock { startTransportLocked() } } + processLifecycle.coroutineScope.launch { interfaceMutex.withLock { startInterfaceLocked() } } initStateListeners() } - override fun isMockTransport(): Boolean = transportFactory.isMockTransport() + override fun isMockInterface(): Boolean = transportFactory.isMockInterface() override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = transportFactory.toInterfaceAddress(interfaceId, rest) @@ -224,17 +206,17 @@ class SharedRadioInterfaceService( _currentDeviceAddressFlow.value = sanitized processLifecycle.coroutineScope.launch { - transportMutex.withLock { - ignoreExceptionSuspend { stopTransportLocked() } - startTransportLocked() + interfaceMutex.withLock { + ignoreException { stopInterfaceLocked() } + startInterfaceLocked() } } return true } - /** Must be called under [transportMutex]. */ - private fun startTransportLocked() { - if (radioTransport != null) return + /** Must be called under [interfaceMutex]. */ + private fun startInterfaceLocked() { + if (radioIf != null) return // Never autoconnect to the simulated node. The mock transport may be offered in the // device-picker UI on debug builds, but it must only connect when the user explicitly @@ -246,26 +228,26 @@ class SharedRadioInterfaceService( return } - Logger.i { "Starting radio transport for ${address.anonymize}" } + Logger.i { "Starting radio interface for ${address.anonymize}" } isStarted = true - runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } - radioTransport = transportFactory.createTransport(address, this) + runningInterfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } + radioIf = transportFactory.createTransport(address, this) startHeartbeat() } - /** Must be called under [transportMutex]. */ - private suspend fun stopTransportLocked() { - val currentTransport = radioTransport - Logger.i { "Stopping transport $currentTransport" } + /** Must be called under [interfaceMutex]. */ + private fun stopInterfaceLocked() { + val currentIf = radioIf + Logger.i { "Stopping interface $currentIf" } isStarted = false - radioTransport = null - runningTransportId = null - currentTransport?.close() + radioIf = null + runningInterfaceId = null + currentIf?.close() - _serviceScope.cancel("stopping transport") + _serviceScope.cancel("stopping interface") _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - if (currentTransport != null) { + if (currentIf != null) { onDisconnect(isPermanent = true) } } @@ -304,25 +286,23 @@ class SharedRadioInterfaceService( fun keepAlive(now: Long = nowMillis) { if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) { - radioTransport?.keepAlive() + radioIf?.keepAlive() lastHeartbeatMillis = now } } override fun sendToRadio(bytes: ByteArray) { - // Snapshot the transport to avoid calling handleSendToRadio on a null reference. - // There is still a benign race: stopTransportLocked() may cancel _serviceScope - // between the null-check and the launch, causing the coroutine to be silently - // dropped. This is acceptable — if the transport is shutting down, dropping the - // send is the correct behavior. - val currentTransport = - radioTransport + // Capture radioIf reference atomically to avoid racing with stopInterfaceLocked() + // which sets radioIf = null and cancels _serviceScope. Without this snapshot, + // we could read a non-null radioIf but launch into an already-cancelled scope. + val currentIf = + radioIf ?: run { - Logger.w { "sendToRadio: no active radio transport, dropping ${bytes.size} bytes" } + Logger.w { "sendToRadio: no active radio interface, dropping ${bytes.size} bytes" } return } _serviceScope.handledLaunch { - currentTransport.handleSendToRadio(bytes) + currentIf.handleSendToRadio(bytes) _meshActivity.tryEmit(MeshActivity.Send) } } @@ -331,28 +311,13 @@ class SharedRadioInterfaceService( override fun handleFromRadio(bytes: ByteArray) { try { lastDataReceivedMillis = nowMillis - // 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" } - } + processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) } _meshActivity.tryEmit(MeshActivity.Receive) } catch (t: Throwable) { Logger.e(t) { "handleFromRadio failed while emitting data" } } } - override fun resetReceivedBuffer() { - // Drain any bytes buffered while no collector was attached. Without this, a stop/start cycle - // would replay stale bytes ahead of the next session's firmware handshake, since the channel - // outlives the orchestrator's per-start scope. - @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") - while (_receivedData.tryReceive().isSuccess) Unit - } - override fun onConnect() { // MutableStateFlow.value is thread-safe (backed by atomics) — assign directly rather than // launching a coroutine. The async launch pattern introduced a window where a concurrent diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/di/CoreServiceModule.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/di/CoreServiceModule.kt index 3fae4287b..d007f1ea3 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/di/CoreServiceModule.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/di/CoreServiceModule.kt @@ -16,19 +16,9 @@ */ package org.meshtastic.core.service.di -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers @Module @ComponentScan("org.meshtastic.core.service") -class CoreServiceModule { - @Single - @Named("ServiceScope") - fun provideServiceScope(dispatchers: CoroutineDispatchers): CoroutineScope = - CoroutineScope(dispatchers.default + SupervisorJob()) -} +class CoreServiceModule diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 87109be1e..611454d05 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -23,10 +23,8 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -43,6 +41,7 @@ import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs @@ -58,10 +57,12 @@ class MeshServiceOrchestratorTest { private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val packetHandler: PacketHandler = mock(MockMode.autofill) private val nodeManager: NodeManager = mock(MockMode.autofill) private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill) private val commandSender: CommandSender = mock(MockMode.autofill) + private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) private val router: MeshRouter = mock(MockMode.autofill) private val actionHandler: MeshActionHandler = mock(MockMode.autofill) private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) @@ -70,13 +71,9 @@ class MeshServiceOrchestratorTest { private val takPrefs: TakPrefs = mock(MockMode.autofill) private val cotHandler: CoTHandler = mock(MockMode.autofill) private val databaseManager: DatabaseManager = mock(MockMode.autofill) - private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) - @OptIn(ExperimentalCoroutinesApi::class) private val testDispatcher = UnconfinedTestDispatcher() - - @OptIn(ExperimentalCoroutinesApi::class) - private val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher) + private val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) /** Stubs the shared flow dependencies used by every test and returns an orchestrator. */ private fun createOrchestrator( @@ -110,16 +107,18 @@ class MeshServiceOrchestratorTest { return MeshServiceOrchestrator( radioInterfaceService = radioInterfaceService, serviceRepository = serviceRepository, + packetHandler = packetHandler, nodeManager = nodeManager, messageProcessor = messageProcessor, + commandSender = commandSender, + connectionManager = connectionManager, router = router, serviceNotifications = serviceNotifications, takServerManager = takServerManager, takMeshIntegration = takMeshIntegration, takPrefs = takPrefs, - databaseManager = databaseManager, - connectionManager = connectionManager, dispatchers = dispatchers, + databaseManager = databaseManager, ) } @@ -132,6 +131,7 @@ class MeshServiceOrchestratorTest { assertTrue(orchestrator.isRunning) verify { serviceNotifications.initChannels() } + verify { packetHandler.start(any()) } verify { nodeManager.loadCachedNodeDB() } orchestrator.stop() @@ -217,84 +217,10 @@ class MeshServiceOrchestratorTest { // Components should only be initialized once verify(exactly(1)) { serviceNotifications.initChannels() } + verify(exactly(1)) { packetHandler.start(any()) } verify(exactly(1)) { nodeManager.loadCachedNodeDB() } orchestrator.stop() assertFalse(orchestrator.isRunning) } - - /** - * Regression test for a bug where `stop()` did not actually tear down the FromRadio collectors. Collectors were - * attached to an injected process-wide ServiceScope rather than a per-start scope, so `start() -> stop() -> - * start()` caused duplicate collectors and every FromRadio packet was handled 2x (then 3x, etc.). - */ - @Test - fun testFromRadioCollectorsTornDownOnStopAndRestartedCleanlyOnStart() { - val receivedData = MutableSharedFlow(extraBufferCapacity = 8) - val orchestrator = createOrchestrator(receivedData = receivedData) - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - - orchestrator.start() - val packet1 = byteArrayOf(1, 2, 3) - receivedData.tryEmit(packet1) - verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet1, null) } - - orchestrator.stop() - val packet2 = byteArrayOf(4, 5, 6) - receivedData.tryEmit(packet2) - // After stop(), the collector must be gone - the handler should not be invoked for packet2. - verifySuspend(exactly(0)) { messageProcessor.handleFromRadio(packet2, null) } - - orchestrator.start() - val packet3 = byteArrayOf(7, 8, 9) - receivedData.tryEmit(packet3) - // After restart, a single fresh collector must process packet3 exactly once (not twice). - verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet3, null) } - - orchestrator.stop() - } - - /** - * Regression test for a channel-buffer-replay bug: the production [RadioInterfaceService] buffers inbound bytes in - * a process-lifetime `Channel(UNLIMITED)`. Between `stop()` and the next `start()`, any bytes that arrive sit in - * the channel and would be replayed to the fresh collector — prepending stale packets to the next session's - * firmware handshake. `start()` must call [RadioInterfaceService.resetReceivedBuffer] before attaching the - * collector. - */ - @Test - fun testStartDrainsReceivedBufferBeforeAttachingCollector() { - val orchestrator = createOrchestrator() - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - - orchestrator.start() - orchestrator.stop() - orchestrator.start() - - // resetReceivedBuffer must be invoked at least once per start() (twice total for two starts). - verify(atLeast(2)) { radioInterfaceService.resetReceivedBuffer() } - - orchestrator.stop() - } - - /** Additional regression: after many start/stop cycles, collectors must not accumulate. */ - @Test - fun testRepeatedStartStopDoesNotAccumulateCollectors() { - val receivedData = MutableSharedFlow(extraBufferCapacity = 8) - val orchestrator = createOrchestrator(receivedData = receivedData) - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - - repeat(5) { - orchestrator.start() - orchestrator.stop() - } - - orchestrator.start() - val packet = byteArrayOf(42) - receivedData.tryEmit(packet) - - // Despite six total start() calls, only the most recent collector is live. - verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet, null) } - - orchestrator.stop() - } } diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt index 5b3d6df0d..8f8e08d45 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okio.BufferedSink import okio.BufferedSource @@ -24,18 +25,17 @@ import okio.buffer import okio.sink import okio.source import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.repository.FileService import java.io.File @Single -class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileService { - override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean = - withContext(dispatchers.io) { +class JvmFileService : FileService { + override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean = + withContext(Dispatchers.IO) { try { - // Treat URI string as a local file path - val file = File(uri.toString()) + // Treat uriString as a local file path + val file = File(uri.uriString) file.parentFile?.mkdirs() file.sink().buffer().use { sink -> block(sink) } true @@ -45,10 +45,10 @@ class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileServic } } - override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean = - withContext(dispatchers.io) { + override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean = + withContext(Dispatchers.IO) { try { - val file = File(uri.toString()) + val file = File(uri.uriString) file.source().buffer().use { source -> block(source) } true } catch (e: Exception) { diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt index 732d03064..cd616417d 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTXml.kt @@ -20,41 +20,47 @@ package org.meshtastic.core.takserver import kotlin.time.Instant -fun CoTMessage.toXml(): String = buildString { - append( +fun CoTMessage.toXml(): String { + val sb = StringBuilder() + sb.append( "", ) contact?.let { - append( + sb.append( "", ) } - group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") } + group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") } - status?.let { append("") } + status?.let { sb.append("") } - track?.let { append("") } + track?.let { sb.append("") } if (chat != null) { val senderUid = uid.geoChatSenderUid() val messageId = uid.geoChatMessageId() - append( + sb.append( "<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'>", ) - append("") - append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>") - append( + sb.append("") + sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>") + sb.append( "${chat.message.xmlEscaped()}", ) } else if (!remarks.isNullOrEmpty()) { - append("${remarks.xmlEscaped()}") + sb.append("${remarks.xmlEscaped()}") } - rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) } + rawDetailXml?.let { + if (it.isNotEmpty()) { + sb.append(it) + } + } - append("") + sb.append("") + return sb.toString() } private fun Instant.toXmlString(): String = this.toString() diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt index 0a47321d6..31248ec41 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt @@ -18,15 +18,12 @@ 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 @@ -61,12 +58,6 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager private val _inboundMessages = MutableSharedFlow() override val inboundMessages: SharedFlow = _inboundMessages.asSharedFlow() - // Unbounded channel preserves FIFO ordering of inbound CoT messages under load. - // onMessage is a non-suspend callback, so we trySend (always succeeds for UNLIMITED) - // and a single consumer coroutine drains into _inboundMessages in order. - private var inboundChannel: Channel? = null - private var inboundDrainJob: Job? = null - private var lastBroadcastPositions = mutableMapOf() override fun start(scope: CoroutineScope) { @@ -77,11 +68,8 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager } scope.launch { - // Wire up inbound message handler BEFORE starting so no messages are lost. - val channel = Channel(Channel.UNLIMITED) - inboundChannel = channel - inboundDrainJob = scope.launch { channel.consumeAsFlow().collect { _inboundMessages.emit(it) } } - takServer.onMessage = { cotMessage -> channel.trySend(cotMessage) } + // Wire up inbound message handler BEFORE starting so no messages are lost + takServer.onMessage = { cotMessage -> scope.launch { _inboundMessages.emit(cotMessage) } } val result = takServer.start(scope) if (result.isSuccess) { @@ -91,10 +79,6 @@ 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 } } } @@ -102,10 +86,6 @@ class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServerManager override fun stop() { takServer.stop() takServer.onMessage = null - inboundChannel?.close() - inboundChannel = null - inboundDrainJob?.cancel() - inboundDrainJob = null _isRunning.value = false scope = null Logger.i { "TAK Server stopped" } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt index 48c635560..65d7077f9 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/CodecExpect.kt @@ -16,16 +16,12 @@ */ package org.meshtastic.core.takserver.fountain -import okio.ByteString.Companion.toByteString - internal expect object ZlibCodec { fun compress(data: ByteArray): ByteArray? fun decompress(data: ByteArray): ByteArray? } -internal object CryptoCodec { - private const val PREFIX_SIZE = 8 - - fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE) +internal expect object CryptoCodec { + fun sha256Prefix8(data: ByteArray): ByteArray } diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt similarity index 83% rename from core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt rename to core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt index b0e4f1030..4473fc521 100644 --- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt +++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt @@ -24,6 +24,8 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.reinterpret import kotlinx.cinterop.usePinned import kotlinx.cinterop.value +import platform.CoreCrypto.CC_SHA256 +import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH import platform.zlib.Z_BUF_ERROR import platform.zlib.Z_OK import platform.zlib.compress @@ -103,3 +105,20 @@ internal actual object ZlibCodec { return null } } + +internal actual object CryptoCodec { + @OptIn(ExperimentalForeignApi::class) + actual fun sha256Prefix8(data: ByteArray): ByteArray { + val digest = ByteArray(CC_SHA256_DIGEST_LENGTH) + if (data.isNotEmpty()) { + data.usePinned { dataPin -> + digest.usePinned { digestPin -> + CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret()) + } + } + } else { + digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) } + } + return digest.copyOf(8) + } +} diff --git a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt similarity index 90% rename from core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt rename to core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt index fca9f0f52..9db28ac66 100644 --- a/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/ZlibCodec.kt +++ b/core/takserver/src/jvmAndroidMain/kotlin/org/meshtastic/core/takserver/fountain/CodecActual.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.takserver.fountain import java.io.ByteArrayOutputStream +import java.security.MessageDigest import java.util.zip.Deflater import java.util.zip.Inflater @@ -65,3 +66,10 @@ internal actual object ZlibCodec { } } } + +internal actual object CryptoCodec { + actual fun sha256Prefix8(data: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(data).copyOf(8) + } +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 8d0b5837a..53c361a62 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -22,7 +22,6 @@ kotlin { android { namespace = "org.meshtastic.core.testing" androidResources.enable = false - withHostTest {} } sourceSets { @@ -32,8 +31,8 @@ kotlin { // Heavy modules (database, data, domain) should depend on core:testing, not vice versa. api(projects.core.model) api(projects.core.repository) - implementation(projects.core.database) - implementation(projects.core.ble) + api(projects.core.database) + api(projects.core.ble) implementation(projects.core.datastore) implementation(libs.androidx.room.runtime) api(libs.kermit) diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 0eb120fbe..2b9f9918f 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -84,12 +84,6 @@ class FakeUiPrefs : UiPrefs { theme.value = value } - override val contrastLevel = MutableStateFlow(0) - - override fun setContrastLevel(value: Int) { - contrastLevel.value = value - } - override val locale = MutableStateFlow("en") override fun setLocale(languageTag: String) { @@ -237,6 +231,15 @@ class FakeMeshPrefs : MeshPrefs { deviceAddress.value = address } + private val provideLocation = mutableMapOf>() + + override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow = + provideLocation.getOrPut(nodeNum) { MutableStateFlow(true) } + + override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) { + provideLocation.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide + } + private val lastRequest = mutableMapOf>() override fun getStoreForwardLastRequest(address: String?): StateFlow = diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt index e5280ec45..27dc3facc 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt @@ -42,7 +42,7 @@ import kotlin.uuid.Uuid class FakeBleDevice( override val address: String, override val name: String? = "Fake Device", - initialState: BleConnectionState = BleConnectionState.Disconnected(), + initialState: BleConnectionState = BleConnectionState.Disconnected, ) : BaseFake(), BleDevice { private val _state = mutableStateFlow(initialState) @@ -124,11 +124,11 @@ class FakeBleConnection : } } - override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState { + override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState { connectException?.let { throw it } if (failNextN > 0) { failNextN-- - return BleConnectionState.Disconnected() + return BleConnectionState.Disconnected } connect(device) return BleConnectionState.Connected @@ -137,9 +137,9 @@ class FakeBleConnection : override suspend fun disconnect() { disconnectCalls++ val currentDevice = _device.value - _connectionState.emit(BleConnectionState.Disconnected()) + _connectionState.emit(BleConnectionState.Disconnected) if (currentDevice is FakeBleDevice) { - currentDevice.setState(BleConnectionState.Disconnected()) + currentDevice.setState(BleConnectionState.Disconnected) } _device.value = null _deviceFlow.emit(null) diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt deleted file mode 100644 index ef8cac0ba..000000000 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDeviceHardwareRepository.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.testing - -import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.repository.DeviceHardwareRepository - -/** - * A test double for [DeviceHardwareRepository] backed by an in-memory map keyed by `(hwModel, target)`. - * - * Call [setHardware] (or [setHardwareForModel]) to seed results, or [setResult] to control the exact [Result] returned - * for a given lookup. By default, lookups return `Result.success(null)`. - */ -class FakeDeviceHardwareRepository : - BaseFake(), - DeviceHardwareRepository { - - private val hardware = mutableMapOf, Result>() - private val calls = mutableListOf>() - - init { - registerResetAction { - hardware.clear() - calls.clear() - } - } - - /** Records every [getDeviceHardwareByModel] invocation for assertion. */ - val recordedCalls: List> - get() = calls.toList() - - override suspend fun getDeviceHardwareByModel( - hwModel: Int, - target: String?, - forceRefresh: Boolean, - ): Result { - calls.add(Triple(hwModel, target, forceRefresh)) - return hardware[hwModel to target] ?: hardware[hwModel to null] ?: Result.success(null) - } - - /** Seeds a successful lookup for the given model/target pair. */ - fun setHardware(hwModel: Int, target: String? = null, device: DeviceHardware?) { - hardware[hwModel to target] = Result.success(device) - } - - /** Seeds a successful lookup for any target of the given model. */ - fun setHardwareForModel(hwModel: Int, device: DeviceHardware?) { - hardware[hwModel to null] = Result.success(device) - } - - /** Seeds an arbitrary [Result] for the given lookup (use to test failure paths). */ - fun setResult(hwModel: Int, target: String? = null, result: Result) { - hardware[hwModel to target] = result - } -} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt deleted file mode 100644 index 166256764..000000000 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.testing - -import kotlinx.coroutines.flow.Flow -import org.meshtastic.core.database.entity.FirmwareRelease -import org.meshtastic.core.repository.FirmwareReleaseRepository - -/** - * A test double for [FirmwareReleaseRepository] that exposes stable and alpha releases as - * [kotlinx.coroutines.flow.MutableStateFlow]s. - * - * Use [setStableRelease] and [setAlphaRelease] to drive the emitted values. - */ -class FakeFirmwareReleaseRepository : - BaseFake(), - FirmwareReleaseRepository { - - private val _stableRelease = mutableStateFlow(null) - private val _alphaRelease = mutableStateFlow(null) - - override val stableRelease: Flow = _stableRelease - override val alphaRelease: Flow = _alphaRelease - - var invalidateCacheCalls: Int = 0 - private set - - init { - registerResetAction { invalidateCacheCalls = 0 } - } - - override suspend fun invalidateCache() { - invalidateCacheCalls++ - } - - fun setStableRelease(release: FirmwareRelease?) { - _stableRelease.value = release - } - - fun setAlphaRelease(release: FirmwareRelease?) { - _alphaRelease.value = release - } -} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt index 4f0a4b153..c90e69da9 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.core.testing -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.proto.ClientNotification @@ -29,7 +28,10 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun initChannels() {} - override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {} + override fun updateServiceStateNotification( + state: org.meshtastic.core.model.ConnectionState, + telemetry: Telemetry?, + ): Any = Any() override suspend fun updateMessageNotification( contactKey: String, diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt deleted file mode 100644 index 215542485..000000000 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeQuickChatActionRepository.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.testing - -import kotlinx.coroutines.flow.Flow -import org.meshtastic.core.database.entity.QuickChatAction -import org.meshtastic.core.repository.QuickChatActionRepository - -/** - * A test double for [QuickChatActionRepository] that keeps actions in an in-memory list (sorted by `position`). - * - * The in-memory list is exposed reactively through [getAllActions]. - */ -class FakeQuickChatActionRepository : - BaseFake(), - QuickChatActionRepository { - - private val actionsFlow = mutableStateFlow>(emptyList()) - - override fun getAllActions(): Flow> = actionsFlow - - override suspend fun upsert(action: QuickChatAction) { - val existingIndex = actionsFlow.value.indexOfFirst { it.uuid == action.uuid } - actionsFlow.value = - if (existingIndex >= 0) { - actionsFlow.value.toMutableList().also { it[existingIndex] = action } - } else { - actionsFlow.value + action - } - .sortedBy { it.position } - } - - override suspend fun deleteAll() { - actionsFlow.value = emptyList() - } - - override suspend fun delete(action: QuickChatAction) { - actionsFlow.value = - actionsFlow.value - .filterNot { it.uuid == action.uuid } - .map { if (it.position > action.position) it.copy(position = it.position - 1) else it } - } - - override suspend fun setItemPosition(uuid: Long, newPos: Int) { - actionsFlow.value = - actionsFlow.value.map { if (it.uuid == uuid) it.copy(position = newPos) else it }.sortedBy { it.position } - } - - /** Seeds the current list of actions (useful for test setup). */ - fun setActions(actions: List) { - actionsFlow.value = actions.sortedBy { it.position } - } - - /** Returns the current in-memory snapshot. */ - val currentActions: List - get() = actionsFlow.value -} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt deleted file mode 100644 index aa68e9b21..000000000 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.testing - -import kotlinx.coroutines.flow.Flow -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.proto.Channel -import org.meshtastic.proto.ChannelSet -import org.meshtastic.proto.ChannelSettings -import org.meshtastic.proto.Config -import org.meshtastic.proto.DeviceProfile -import org.meshtastic.proto.DeviceUIConfig -import org.meshtastic.proto.FileInfo -import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.LocalModuleConfig -import org.meshtastic.proto.ModuleConfig - -/** - * A test double for [RadioConfigRepository] backed by in-memory [kotlinx.coroutines.flow.MutableStateFlow]s. - * - * All mutator methods update the underlying state flows synchronously so tests can observe changes immediately. - * [deviceProfileFlow] is derived from [localConfigFlow], [moduleConfigFlow], and the current channel set. - */ -@Suppress("TooManyFunctions") -class FakeRadioConfigRepository : - BaseFake(), - RadioConfigRepository { - - private val channelSetBacking = mutableStateFlow(ChannelSet()) - override val channelSetFlow: Flow = channelSetBacking - - private val localConfigBacking = mutableStateFlow(LocalConfig()) - override val localConfigFlow: Flow = localConfigBacking - - private val moduleConfigBacking = mutableStateFlow(LocalModuleConfig()) - override val moduleConfigFlow: Flow = moduleConfigBacking - - private val deviceProfileBacking = mutableStateFlow(DeviceProfile()) - override val deviceProfileFlow: Flow = deviceProfileBacking - val currentDeviceProfile: DeviceProfile - get() = deviceProfileBacking.value - - private val deviceUIConfigBacking = mutableStateFlow(null) - override val deviceUIConfigFlow: Flow = deviceUIConfigBacking - - private val fileManifestBacking = mutableStateFlow>(emptyList()) - override val fileManifestFlow: Flow> = fileManifestBacking - - val currentChannelSet: ChannelSet - get() = channelSetBacking.value - - val currentLocalConfig: LocalConfig - get() = localConfigBacking.value - - val currentModuleConfig: LocalModuleConfig - get() = moduleConfigBacking.value - - val currentDeviceUIConfig: DeviceUIConfig? - get() = deviceUIConfigBacking.value - - val currentFileManifest: List - get() = fileManifestBacking.value - - /** - * Last [Config] passed to [setLocalConfig] (null until called). Tests should use [setLocalConfigDirect] to drive - * state. - */ - var lastSetLocalConfig: Config? = null - private set - - /** Last [ModuleConfig] passed to [setLocalModuleConfig] (null until called). */ - var lastSetModuleConfig: ModuleConfig? = null - private set - - init { - registerResetAction { - lastSetLocalConfig = null - lastSetModuleConfig = null - } - } - - override suspend fun clearChannelSet() { - channelSetBacking.value = ChannelSet() - } - - override suspend fun replaceAllSettings(settingsList: List) { - channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList) - } - - override suspend fun updateChannelSettings(channel: Channel) { - val current = channelSetBacking.value.settings.toMutableList() - while (current.size <= channel.index) current.add(ChannelSettings()) - current[channel.index] = channel.settings ?: ChannelSettings() - channelSetBacking.value = channelSetBacking.value.copy(settings = current) - } - - override suspend fun clearLocalConfig() { - localConfigBacking.value = LocalConfig() - } - - override suspend fun setLocalConfig(config: Config) { - lastSetLocalConfig = config - } - - override suspend fun clearLocalModuleConfig() { - moduleConfigBacking.value = LocalModuleConfig() - } - - override suspend fun setLocalModuleConfig(config: ModuleConfig) { - lastSetModuleConfig = config - } - - override suspend fun setDeviceUIConfig(config: DeviceUIConfig) { - deviceUIConfigBacking.value = config - } - - override suspend fun clearDeviceUIConfig() { - deviceUIConfigBacking.value = null - } - - override suspend fun addFileInfo(info: FileInfo) { - fileManifestBacking.value = fileManifestBacking.value + info - } - - override suspend fun clearFileManifest() { - fileManifestBacking.value = emptyList() - } - - /** Directly sets the [LocalConfig] without merging (preferred for test setup). */ - fun setLocalConfigDirect(config: LocalConfig) { - localConfigBacking.value = config - } - - /** Directly sets the [LocalModuleConfig] without merging (preferred for test setup). */ - fun setLocalModuleConfigDirect(config: LocalModuleConfig) { - moduleConfigBacking.value = config - } - - /** Directly sets the combined [DeviceProfile] emitted by [deviceProfileFlow]. */ - fun setDeviceProfile(profile: DeviceProfile) { - deviceProfileBacking.value = profile - } - - /** Directly sets the [ChannelSet] (bypasses [updateChannelSettings]/[replaceAllSettings]). */ - fun setChannelSet(channelSet: ChannelSet) { - channelSetBacking.value = channelSet - } -} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index d23a7f1ec..bf83be372 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -19,13 +19,8 @@ package org.meshtastic.core.testing import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController -import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.User /** * A test double for [RadioController] that provides a no-op implementation and tracks calls for assertions in tests. @@ -35,7 +30,6 @@ class FakeRadioController : BaseFake(), RadioController { - /** Canonical app-level connection state, mirroring [ServiceRepository][connectionState] semantics. */ private val _connectionState = mutableStateFlow(ConnectionState.Connected) override val connectionState: StateFlow = _connectionState @@ -84,19 +78,19 @@ class FakeRadioController : return true } - override suspend fun setLocalConfig(config: Config) {} + override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {} - override suspend fun setLocalChannel(channel: Channel) {} + override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {} - override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {} + override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {} - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {} + override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {} - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {} + override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {} - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {} + override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {} - override suspend fun setFixedPosition(destNum: Int, position: Position) {} + override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {} override suspend fun setRingtone(destNum: Int, ringtone: String) {} @@ -130,7 +124,7 @@ class FakeRadioController : override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {} - override suspend fun requestPosition(destNum: Int, currentPosition: Position) {} + override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {} override suspend fun requestUserInfo(destNum: Int) {} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt index d3f8dc71e..e1a26c6c3 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt @@ -18,43 +18,30 @@ 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 import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.repository.RadioInterfaceService -/** - * A test double for [RadioInterfaceService] that provides an in-memory implementation. - * - * The [connectionState] here mirrors the transport-level semantics of the real implementation. In production, only - * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager] observes this flow; tests should verify - * that bridging behavior rather than consuming it directly from UI/feature test code (use - * [FakeServiceRepository.connectionState] instead). - */ +/** A test double for [RadioInterfaceService] that provides an in-memory implementation. */ @Suppress("TooManyFunctions") class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService { override val supportedDeviceTypes: List = emptyList() - /** Transport-level connection state (raw hardware link status). */ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow = _connectionState private val _currentDeviceAddressFlow = MutableStateFlow(null) override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow - // Use an unbounded Channel to mirror SharedRadioInterfaceService semantics. A MutableSharedFlow would - // hide the stop/start backlog bug that motivated the resetReceivedBuffer() API. - private val _receivedData = Channel(Channel.UNLIMITED) - override val receivedData: Flow = _receivedData.receiveAsFlow() + private val _receivedData = MutableSharedFlow() + override val receivedData: SharedFlow = _receivedData private val _meshActivity = MutableSharedFlow() override val meshActivity: SharedFlow = _meshActivity @@ -65,7 +52,7 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main val sentToRadio = mutableListOf() var connectCalled = false - override fun isMockTransport(): Boolean = true + override fun isMockInterface(): Boolean = true override fun sendToRadio(bytes: ByteArray) { sentToRadio.add(bytes) @@ -93,18 +80,13 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main } override fun handleFromRadio(bytes: ByteArray) { - _receivedData.trySend(bytes) - } - - override fun resetReceivedBuffer() { - @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") - while (_receivedData.tryReceive().isSuccess) Unit + // In a real implementation, this would emit to receivedData } // --- Helper methods for testing --- - fun emitFromRadio(bytes: ByteArray) { - _receivedData.trySend(bytes) + suspend fun emitFromRadio(bytes: ByteArray) { + _receivedData.emit(bytes) } fun setConnectionState(state: ConnectionState) { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt index 492802426..66afa69be 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt @@ -32,7 +32,7 @@ class FakeRadioTransport : RadioTransport { keepAliveCalled = true } - override suspend fun close() { + override fun close() { closeCalled = true } } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt index ae06843b6..266a0d958 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt @@ -31,7 +31,6 @@ import org.meshtastic.proto.MeshPacket @Suppress("TooManyFunctions") class FakeServiceRepository : ServiceRepository { - /** Canonical app-level connection state — the single source of truth for UI/feature tests. */ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow = _connectionState diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt deleted file mode 100644 index a52b86bd0..000000000 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeTracerouteSnapshotRepository.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.testing - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import org.meshtastic.core.repository.TracerouteSnapshotRepository -import org.meshtastic.proto.Position - -/** - * A test double for [TracerouteSnapshotRepository] keyed by `logUuid`. - * - * Use [upsertSnapshotPositions] as you would in production, or [seedSnapshot] to directly inject state for a log. - */ -class FakeTracerouteSnapshotRepository : - BaseFake(), - TracerouteSnapshotRepository { - - private val snapshots = mutableStateFlow>>(emptyMap()) - private val requestIds = mutableMapOf() - - init { - registerResetAction { requestIds.clear() } - } - - override fun getSnapshotPositions(logUuid: String): Flow> = - snapshots.map { it[logUuid].orEmpty() } - - override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) { - requestIds[logUuid] = requestId - snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } - } - - /** Directly seeds the snapshot for a log (bypasses request-id tracking). */ - fun seedSnapshot(logUuid: String, positions: Map) { - snapshots.value = snapshots.value.toMutableMap().also { it[logUuid] = positions } - } - - /** Returns the last request-id recorded for [logUuid], or `null` if none. */ - fun lastRequestId(logUuid: String): Int? = requestIds[logUuid] -} diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt deleted file mode 100644 index f9a63c712..000000000 --- a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.testing - -import app.cash.turbine.test -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.database.entity.FirmwareRelease -import org.meshtastic.core.database.entity.QuickChatAction -import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.proto.Channel -import org.meshtastic.proto.ChannelSettings -import org.meshtastic.proto.Position -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class RepositoryFakesTest { - - @Test - fun `FakeDeviceHardwareRepository returns seeded hardware and records calls`() = runTest { - val repo = FakeDeviceHardwareRepository() - val hw = DeviceHardware(hwModel = 42, hwModelSlug = "TEST", platformioTarget = "tlora") - repo.setHardware(hwModel = 42, target = "tlora", device = hw) - - val hit = repo.getDeviceHardwareByModel(hwModel = 42, target = "tlora", forceRefresh = false) - val miss = repo.getDeviceHardwareByModel(hwModel = 99) - - assertEquals(hw, hit.getOrNull()) - assertNull(miss.getOrNull()) - assertEquals(2, repo.recordedCalls.size) - assertEquals(Triple(42, "tlora", false), repo.recordedCalls.first()) - } - - @Test - fun `FakeFirmwareReleaseRepository emits stable and alpha releases`() = runTest { - val repo = FakeFirmwareReleaseRepository() - val stable = FirmwareRelease(id = "1.0", title = "1.0", pageUrl = "", zipUrl = "") - val alpha = FirmwareRelease(id = "1.1-a", title = "1.1-a", pageUrl = "", zipUrl = "") - - repo.setStableRelease(stable) - repo.setAlphaRelease(alpha) - - assertEquals(stable, repo.stableRelease.first()) - assertEquals(alpha, repo.alphaRelease.first()) - - repo.invalidateCache() - repo.invalidateCache() - assertEquals(2, repo.invalidateCacheCalls) - } - - @Test - fun `FakeQuickChatActionRepository upsert delete and reorder`() = runTest { - val repo = FakeQuickChatActionRepository() - val a = QuickChatAction(uuid = 1L, name = "A", message = "hi", position = 0) - val b = QuickChatAction(uuid = 2L, name = "B", message = "bye", position = 1) - - repo.upsert(a) - repo.upsert(b) - assertEquals(listOf(a, b), repo.getAllActions().first()) - - repo.setItemPosition(uuid = 1L, newPos = 5) - assertEquals(listOf(2L, 1L), repo.getAllActions().first().map { it.uuid }) - - repo.delete(b) - assertEquals(1, repo.currentActions.size) - - repo.deleteAll() - assertTrue(repo.currentActions.isEmpty()) - } - - @Test - fun `FakeQuickChatActionRepository delete compacts positions`() = runTest { - val repo = FakeQuickChatActionRepository() - val a = QuickChatAction(uuid = 1L, name = "A", message = "", position = 0) - val b = QuickChatAction(uuid = 2L, name = "B", message = "", position = 1) - val c = QuickChatAction(uuid = 3L, name = "C", message = "", position = 2) - repo.upsert(a) - repo.upsert(b) - repo.upsert(c) - - repo.delete(b) - - // Matches real DAO's decrementPositionsAfter: positions must stay contiguous. - assertEquals(listOf(1L to 0, 3L to 1), repo.currentActions.map { it.uuid to it.position }) - } - - @Test - fun `FakeTracerouteSnapshotRepository roundtrips positions keyed by log uuid`() = runTest { - val repo = FakeTracerouteSnapshotRepository() - val positions = mapOf(1 to Position(latitude_i = 10), 2 to Position(latitude_i = 20)) - repo.upsertSnapshotPositions(logUuid = "log-1", requestId = 99, positions = positions) - - repo.getSnapshotPositions("log-1").test { assertEquals(positions, awaitItem()) } - assertEquals(99, repo.lastRequestId("log-1")) - assertNull(repo.lastRequestId("other")) - } - - @Test - fun `FakeRadioConfigRepository tracks channel set and module config`() = runTest { - val repo = FakeRadioConfigRepository() - val a = ChannelSettings(name = "A") - val b = ChannelSettings(name = "B") - - repo.replaceAllSettings(listOf(a, b)) - assertEquals(listOf(a, b), repo.currentChannelSet.settings) - - repo.updateChannelSettings(Channel(index = 1, settings = ChannelSettings(name = "B2"))) - assertEquals("B2", repo.currentChannelSet.settings[1].name) - - repo.clearChannelSet() - assertTrue(repo.currentChannelSet.settings.isEmpty()) - } -} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 44b483c91..bbe3204e5 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -26,6 +26,7 @@ kotlin { android { namespace = "org.meshtastic.core.ui" androidResources.enable = false + withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -42,7 +43,6 @@ kotlin { implementation(projects.core.resources) implementation(projects.core.service) - implementation(libs.compose.multiplatform.animation) implementation(libs.compose.multiplatform.material3) implementation(libs.compose.multiplatform.ui) implementation(libs.compose.multiplatform.foundation) @@ -55,11 +55,11 @@ kotlin { implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite) + implementation(libs.jetbrains.navigationevent.compose) implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.compose.material3.adaptive.navigation3) implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) implementation(libs.jetbrains.lifecycle.viewmodel.compose) - implementation(libs.jetbrains.lifecycle.runtime.compose) } val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } } @@ -70,9 +70,8 @@ kotlin { implementation(projects.core.testing) implementation(libs.junit) implementation(libs.kotlinx.coroutines.test) - implementation(libs.compose.multiplatform.ui.test) } - jvmTest.dependencies { implementation(compose.desktop.currentOs) } + val androidHostTest by getting { dependencies { implementation(libs.androidx.test.runner) } } } } diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index aa47539bb..f8b0586f4 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -27,18 +27,17 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import org.meshtastic.core.common.util.nowMillis @Composable actual fun rememberTimeTickWithLifecycle(): Long { val context = LocalContext.current - var value by remember { mutableLongStateOf(nowMillis) } + var value by remember { mutableLongStateOf(System.currentTimeMillis()) } DisposableEffect(context) { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - value = nowMillis + value = System.currentTimeMillis() } } diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 5365ab95e..97a24d54e 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -20,29 +20,24 @@ package org.meshtastic.core.ui.util import android.content.ActivityNotFoundException import android.content.Intent +import android.net.Uri import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect import co.touchlab.kermit.Logger -import com.eygraber.uri.toAndroidUri -import com.eygraber.uri.toKmpUri +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString -import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.common.util.MeshtasticUri import java.net.URLEncoder @Composable @@ -107,14 +102,16 @@ actual fun rememberOpenUrl(): (url: String) -> Unit { @Composable @Suppress("Wrapping") actual fun rememberSaveFileLauncher( - onUriReceived: (org.meshtastic.core.common.util.CommonUri) -> Unit, + onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit { val launcher = androidx.activity.compose.rememberLauncherForActivityResult( androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(), ) { result -> if (result.resultCode == android.app.Activity.RESULT_OK) { - result.data?.data?.let { uri -> onUriReceived(uri.toKmpUri()) } + result.data?.data?.let { uri -> + onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) }) + } } } @@ -135,21 +132,21 @@ actual fun rememberSaveFileLauncher( actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit { val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - onUriReceived(uri?.let { it.toKmpUri() }) + onUriReceived(uri?.let { CommonUri(it) }) } return remember(launcher) { { mimeType -> launcher.launch(mimeType) } } } @Suppress("Wrapping") @Composable -actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? { +actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? { val context = LocalContext.current return remember(context) { { uri, maxChars -> - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { @Suppress("TooGenericExceptionCaught") try { - val androidUri = uri.toAndroidUri() + val androidUri = Uri.parse(uri.toString()) context.contentResolver.openInputStream(androidUri)?.use { stream -> stream.bufferedReader().use { reader -> val buffer = CharArray(maxChars) @@ -219,67 +216,3 @@ actual fun rememberOpenLocationSettings(): () -> Unit { } return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } } } - -@Composable -actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { - // On pre-Android 12, BLE scanning is gated by location permission, not Bluetooth. - return remember { { onGranted() } } - } - val currentOnGranted = rememberUpdatedState(onGranted) - val currentOnDenied = rememberUpdatedState(onDenied) - val launcher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - if (permissions.values.all { it }) currentOnGranted.value() else currentOnDenied.value() - } - return remember(launcher) { - { - launcher.launch( - arrayOf(android.Manifest.permission.BLUETOOTH_SCAN, android.Manifest.permission.BLUETOOTH_CONNECT), - ) - } - } -} - -@Composable -actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { - // Pre-Android 13, no runtime notification permission required. - return remember { { onGranted() } } - } - val currentOnGranted = rememberUpdatedState(onGranted) - val currentOnDenied = rememberUpdatedState(onDenied) - val launcher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) currentOnGranted.value() else currentOnDenied.value() - } - return remember(launcher) { { launcher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } } -} - -@Composable -actual fun isLocationPermissionGranted(): Boolean { - val context = LocalContext.current - return rememberOnResumeState { - androidx.core.content.ContextCompat.checkSelfPermission( - context, - android.Manifest.permission.ACCESS_FINE_LOCATION, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - } -} - -@Composable -actual fun isGpsDisabled(): Boolean { - val context = LocalContext.current - return rememberOnResumeState { context.gpsDisabled() } -} - -/** - * Remembers a boolean state that is re-evaluated on each [Lifecycle.Event.ON_RESUME], ensuring the value stays fresh - * when the user returns from a permission dialog or system settings screen. - */ -@Composable -private fun rememberOnResumeState(check: () -> Boolean): Boolean { - val state = remember { mutableStateOf(check()) } - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { state.value = check() } - return state.value -} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt similarity index 54% rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt rename to core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt index 7a442980f..b6abd64b0 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt +++ b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt @@ -16,46 +16,28 @@ */ package org.meshtastic.core.ui.component -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.v2.runComposeUiTest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain +import org.junit.Rule +import org.junit.Test import org.meshtastic.core.ui.util.AlertManager -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) class AlertHostTest { - private val testDispatcher = UnconfinedTestDispatcher() - - @BeforeTest - fun setUp() { - Dispatchers.setMain(testDispatcher) - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } + @get:Rule val composeTestRule = createComposeRule() @Test - fun alertHost_showsDialog_whenAlertIsTriggered() = runComposeUiTest { + fun alertHost_showsDialog_whenAlertIsTriggered() { val alertManager = AlertManager() val title = "Alert Title" val message = "Alert Message" - setContent { AlertHost(alertManager = alertManager) } + composeTestRule.setContent { AlertHost(alertManager = alertManager) } alertManager.showAlert(title = title, message = message) - onNodeWithText(title).assertIsDisplayed() - onNodeWithText(message).assertIsDisplayed() + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText(message).assertIsDisplayed() } } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt similarity index 63% rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt rename to core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt index 8380aabcb..cc4f32b8e 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt +++ b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt @@ -18,25 +18,27 @@ package org.meshtastic.core.ui.component import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertDoesNotExist import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.v2.runComposeUiTest +import org.junit.Rule +import org.junit.Test import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User -import kotlin.test.Test -@OptIn(ExperimentalTestApi::class) class ImportFabUiTest { + @get:Rule val composeTestRule = createComposeRule() + @Test - fun importFab_expands_onButtonClick_whenSupported() = runComposeUiTest { + fun importFab_expands_onButtonClick_whenSupported() { val testTag = "import_fab" - setContent { + composeTestRule.setContent { CompositionLocalProvider( LocalBarcodeScannerSupported provides true, LocalNfcScannerSupported provides true, @@ -46,18 +48,18 @@ class ImportFabUiTest { } // Expand the FAB - onNodeWithTag(testTag).performClick() + composeTestRule.onNodeWithTag(testTag).performClick() // Verify menu items are visible using their tags - onNodeWithTag("nfc_import").assertIsDisplayed() - onNodeWithTag("qr_import").assertIsDisplayed() - onNodeWithTag("url_import").assertIsDisplayed() + composeTestRule.onNodeWithTag("nfc_import").assertIsDisplayed() + composeTestRule.onNodeWithTag("qr_import").assertIsDisplayed() + composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() } @Test - fun importFab_hidesNfcAndQr_whenNotSupported() = runComposeUiTest { + fun importFab_hidesNfcAndQr_whenNotSupported() { val testTag = "import_fab" - setContent { + composeTestRule.setContent { CompositionLocalProvider( LocalBarcodeScannerSupported provides false, LocalNfcScannerSupported provides false, @@ -67,41 +69,41 @@ class ImportFabUiTest { } // Expand the FAB - onNodeWithTag(testTag).performClick() + composeTestRule.onNodeWithTag(testTag).performClick() // Verify menu items are visible using their tags - onNodeWithTag("nfc_import").assertDoesNotExist() - onNodeWithTag("qr_import").assertDoesNotExist() - onNodeWithTag("url_import").assertIsDisplayed() + composeTestRule.onNodeWithTag("nfc_import").assertDoesNotExist() + composeTestRule.onNodeWithTag("qr_import").assertDoesNotExist() + composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() } @Test - fun importFab_showsUrlDialog_whenUrlItemClicked() = runComposeUiTest { + fun importFab_showsUrlDialog_whenUrlItemClicked() { val testTag = "import_fab" - setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } + composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } - onNodeWithTag(testTag).performClick() - onNodeWithTag("url_import").performClick() + composeTestRule.onNodeWithTag(testTag).performClick() + composeTestRule.onNodeWithTag("url_import").performClick() // The URL dialog should be shown. // We'll search for its title indirectly or check if an AlertDialog appeared. } @Test - fun importFab_showsShareChannels_whenCallbackProvided() = runComposeUiTest { + fun importFab_showsShareChannels_whenCallbackProvided() { val testTag = "import_fab" - setContent { + composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, onShareChannels = {}, isContactContext = false, testTag = testTag) } - onNodeWithTag(testTag).performClick() - onNodeWithTag("share_channels").assertIsDisplayed() + composeTestRule.onNodeWithTag(testTag).performClick() + composeTestRule.onNodeWithTag("share_channels").assertIsDisplayed() } @Test - fun importFab_showsSharedContactDialog_whenProvided() = runComposeUiTest { + fun importFab_showsSharedContactDialog_whenProvided() { val contact = SharedContact(user = User(long_name = "Suzume Goddess"), node_num = 1) - setContent { + composeTestRule.setContent { MeshtasticImportFAB( onImport = {}, sharedContact = contact, @@ -111,6 +113,6 @@ class ImportFabUiTest { } // Check if goddess is here - onNodeWithText("Importing Suzume Goddess").assertIsDisplayed() + composeTestRule.onNodeWithText("Importing Suzume Goddess").assertIsDisplayed() } } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt similarity index 61% rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt rename to core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt index 2090736b1..5632d39c1 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt +++ b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt @@ -18,21 +18,22 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.v2.runComposeUiTest -import kotlin.test.Test -import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.Test -@OptIn(ExperimentalTestApi::class) class AlertManagerUiTest { + @get:Rule val composeTestRule = createComposeRule() + + private val alertManager = AlertManager() + @Test - fun alertManager_showsAlert_whenRequested() = runComposeUiTest { - val alertManager = AlertManager() - setContent { + fun alertManager_showsAlert_whenRequested() { + composeTestRule.setContent { val alertData by alertManager.currentAlert.collectAsState() alertData?.let { data -> AlertPreviewRenderer(data) } } @@ -42,24 +43,29 @@ class AlertManagerUiTest { alertManager.showAlert(title = title, message = message) - onNodeWithText(title).assertIsDisplayed() - onNodeWithText(message).assertIsDisplayed() + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText(message).assertIsDisplayed() } @Test - fun alertManager_confirmButton_triggersCallbackAndDismisses() = runComposeUiTest { - val alertManager = AlertManager() + fun alertManager_confirmButton_triggersCallbackAndDismisses() { var confirmClicked = false - setContent { + composeTestRule.setContent { val alertData by alertManager.currentAlert.collectAsState() alertData?.let { data -> AlertPreviewRenderer(data) } } + alertManager.showAlert(title = "Confirm Title", onConfirm = { confirmClicked = true }) + + // Default confirm text is "Okay" from resources, but AlertPreviewRenderer uses it + // We'll search for the text "Okay" (assuming it matches the resource value) + // Since we are in a test, we might need to use a hardcoded string or a resource + // But for this test, let's just use the confirmText parameter to be sure alertManager.showAlert(title = "Confirm Title", confirmText = "Yes", onConfirm = { confirmClicked = true }) - onNodeWithText("Yes").performClick() + composeTestRule.onNodeWithText("Yes").performClick() - assertTrue(confirmClicked) - onNodeWithText("Confirm Title").assertDoesNotExist() + assert(confirmClicked) + composeTestRule.onNodeWithText("Confirm Title").assertDoesNotExist() } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt index 125e1e117..7330c1aa6 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ClickableTextField.kt @@ -38,7 +38,6 @@ fun ClickableTextField( onClick: () -> Unit, modifier: Modifier = Modifier, isError: Boolean = false, - trailingIconContentDescription: String? = null, ) { val source = remember { MutableInteractionSource() } val isPressed by source.collectIsPressedAsState() @@ -50,7 +49,7 @@ fun ClickableTextField( enabled = enabled, readOnly = true, label = { Text(stringResource(label)) }, - trailingIcon = { Icon(trailingIcon, trailingIconContentDescription) }, + trailingIcon = { Icon(trailingIcon, null) }, isError = isError, interactionSource = source, modifier = modifier, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt index 22c6bfaf5..9d41d5f5a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt @@ -62,13 +62,12 @@ fun > DropDownPreference( enumEntriesOf(selectedItem).filter { it.name != "UNRECOGNIZED" && !it.isDeprecatedEnumEntry() } } - val items = - enumConstants.map { - val label = itemLabel?.invoke(it) ?: it.name - val icon = itemIcon?.invoke(it) - val color = itemColor?.invoke(it) - DropDownItem(it, label, icon, color) - } + val items = enumConstants.map { + val label = itemLabel?.invoke(it) ?: it.name + val icon = itemIcon?.invoke(it) + val color = itemColor?.invoke(it) + DropDownItem(it, label, icon, color) + } DropDownPreference( title = title, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt index 10b83ce41..681952e61 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditPasswordPreference.kt @@ -19,11 +19,11 @@ package org.meshtastic.core.ui.component import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon -import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction @@ -36,7 +36,6 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.hide_password import org.meshtastic.core.resources.show_password import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Visibility import org.meshtastic.core.ui.icon.VisibilityOff @Composable @@ -49,7 +48,7 @@ fun EditPasswordPreference( onValueChanged: (String) -> Unit, modifier: Modifier = Modifier, ) { - var isPasswordVisible by rememberSaveable { mutableStateOf(false) } + var isPasswordVisible by remember { mutableStateOf(false) } EditTextPreference( title = title, @@ -64,9 +63,10 @@ fun EditPasswordPreference( onFocusChanged = {}, visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - IconToggleButton(checked = isPasswordVisible, onCheckedChange = { isPasswordVisible = it }) { + IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) { Icon( - imageVector = if (isPasswordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, + imageVector = + if (isPasswordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.VisibilityOff, contentDescription = if (isPasswordVisible) { stringResource(Res.string.hide_password) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt index d8df4101b..c461a065f 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -91,10 +90,10 @@ fun MeshtasticImportFAB( ) { sharedContact?.let { importDialog(it, onDismissSharedContact) } - var expanded by rememberSaveable { mutableStateOf(false) } - var showUrlDialog by rememberSaveable { mutableStateOf(false) } - var isNfcScanning by rememberSaveable { mutableStateOf(false) } - var showNfcDisabledDialog by rememberSaveable { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + var showUrlDialog by remember { mutableStateOf(false) } + var isNfcScanning by remember { mutableStateOf(false) } + var showNfcDisabledDialog by remember { mutableStateOf(false) } val openNfcSettings = rememberOpenNfcSettings() val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt index 2fa66b468..b84c11e13 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -59,7 +58,6 @@ import org.meshtastic.core.resources.preview_gauge import org.meshtastic.core.resources.preview_gradient import org.meshtastic.core.resources.preview_pill import org.meshtastic.core.resources.preview_text -import org.meshtastic.core.resources.show_iaq_legend import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.ThumbUp import org.meshtastic.core.ui.icon.Warning @@ -122,18 +120,13 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil Column { when (displayMode) { IaqDisplayMode.Pill -> { - val legendLabel = stringResource(Res.string.show_iaq_legend) Box( modifier = Modifier.clip(RoundedCornerShape(10.dp)) .background(iaqEnum.color) .width(125.dp) .height(30.dp) - .clickable( - onClickLabel = legendLabel, - role = Role.Button, - onClick = { isLegendOpen = true }, - ), + .clickable { isLegendOpen = true }, ) { Row( modifier = Modifier.padding(4.dp).align(Alignment.CenterStart), @@ -151,15 +144,7 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Dot -> { - val legendLabel = stringResource(Res.string.show_iaq_legend) - Column( - modifier = - Modifier.clickable( - onClickLabel = legendLabel, - role = Role.Button, - onClick = { isLegendOpen = true }, - ), - ) { + Column(modifier = Modifier.clickable { isLegendOpen = true }) { Row(verticalAlignment = Alignment.CenterVertically) { Text(text = "$iaq") Spacer(modifier = Modifier.width(4.dp)) @@ -169,30 +154,17 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Text -> { - val legendLabel = stringResource(Res.string.show_iaq_legend) Text( text = getIaqDescriptionWithRange(iaqEnum), fontSize = 12.sp, - modifier = - Modifier.clickable( - onClickLabel = legendLabel, - role = Role.Button, - onClick = { isLegendOpen = true }, - ), + modifier = Modifier.clickable { isLegendOpen = true }, ) } IaqDisplayMode.Gauge -> { - val legendLabel = stringResource(Res.string.show_iaq_legend) CircularProgressIndicator( progress = { iaq / 500f }, - modifier = - Modifier.size(60.dp) - .clickable( - onClickLabel = legendLabel, - role = Role.Button, - onClick = { isLegendOpen = true }, - ), + modifier = Modifier.size(60.dp).clickable { isLegendOpen = true }, strokeWidth = 8.dp, color = iaqEnum.color, ) @@ -200,15 +172,9 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } IaqDisplayMode.Gradient -> { - val legendLabel = stringResource(Res.string.show_iaq_legend) Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier.clickable( - onClickLabel = legendLabel, - role = Role.Button, - onClick = { isLegendOpen = true }, - ), + modifier = Modifier.clickable { isLegendOpen = true }, ) { LinearProgressIndicator( progress = { iaq / 500f }, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt index 753468600..216ec2108 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt @@ -41,7 +41,7 @@ import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bad import org.meshtastic.core.resources.fair @@ -154,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) { Text( modifier = modifier, - text = "${stringResource(Res.string.snr)} ${MetricFormatter.snr(snr, decimalPlaces = 2)}", + text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr), color = color, style = MaterialTheme.typography.labelSmall, ) @@ -172,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) { } Text( modifier = modifier, - text = "${stringResource(Res.string.rssi)} ${MetricFormatter.rssi(rssi)}", + text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi), color = color, style = MaterialTheme.typography.labelSmall, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt index 1445bdedf..7e8bd9b6a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.icon.BatteryEmpty @@ -49,6 +49,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusRed +private const val FORMAT = "%d%%" private const val SIZE_ICON = 16 @Suppress("MagicNumber", "LongMethod") @@ -59,7 +60,7 @@ fun MaterialBatteryInfo( voltage: Float? = null, contentColor: Color = MaterialTheme.colorScheme.onSurface, ) { - val levelString = level?.let { MetricFormatter.percent(it) } ?: stringResource(Res.string.unknown) + val levelString = formatString(FORMAT, level) Row( modifier = modifier, @@ -129,7 +130,7 @@ fun MaterialBatteryInfo( ?.takeIf { it > 0 } ?.let { Text( - text = MetricFormatter.voltage(it), + text = formatString("%.2fV", it), color = contentColor.copy(alpha = 0.8f), style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp), ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt index 153f5a058..8c96e88a4 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.NodeDetailRoute -import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.ui.viewmodel.UIViewModel /** @@ -44,11 +43,8 @@ fun MeshtasticAppShell( MeshtasticCommonAppSetup( uiViewModel = uiViewModel, onNavigateToTracerouteMap = { destNum, requestId, logUuid -> - multiBackstack.handleDeepLink( - listOf( - NodesRoute.NodesGraph, - NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), - ), + multiBackstack.activeBackStack.add( + NodeDetailRoute.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid), ) }, ) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt index 6bf0065bf..37e354d32 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ElevatedButton -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -44,28 +43,22 @@ fun PreferenceFooter( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - val mediumHeight = ButtonDefaults.MediumContainerHeight if (negativeText != null) { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) ElevatedButton( - shapes = ButtonDefaults.shapesFor(mediumHeight), - modifier = Modifier.height(mediumHeight).weight(1f), + modifier = Modifier.height(48.dp).weight(1f), colors = ButtonDefaults.filledTonalButtonColors(), onClick = onNegativeClicked, ) { - Text(text = negativeText, style = ButtonDefaults.textStyleFor(mediumHeight)) + Text(text = negativeText) } } if (positiveText != null) { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) ElevatedButton( - shapes = ButtonDefaults.shapesFor(mediumHeight), - modifier = Modifier.height(mediumHeight).weight(1f), + modifier = Modifier.height(48.dp).weight(1f), colors = ButtonDefaults.buttonColors(), onClick = { if (enabled) onPositiveClicked() }, ) { - Text(text = positiveText, style = ButtonDefaults.textStyleFor(mediumHeight)) + Text(text = positiveText) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt index f9f839ea5..afa82460d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/RegularPreference.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -81,13 +80,7 @@ fun RegularPreference( MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) } - Column( - modifier = - modifier - .fillMaxWidth() - .clickable(enabled = enabled, onClick = onClick, role = Role.Button) - .padding(all = 16.dp), - ) { + Column(modifier = modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(all = 16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { FlowRow(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt index f817ec4e4..5a6c58c23 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.signal_quality @@ -65,10 +65,7 @@ fun SignalInfo( tint = signalColor, ) Text( - text = - "${MetricFormatter.snr( - node.snr, - )} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}", + text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)), style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt index a0b87ca6a..100c6fecb 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TracerouteAlertHandler.kt @@ -26,12 +26,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import kotlinx.coroutines.launch import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.okay import org.meshtastic.core.resources.traceroute @@ -55,7 +52,6 @@ fun TracerouteAlertHandler( val traceRouteResponse by uiViewModel.tracerouteResponse.collectAsStateWithLifecycle(null) var dismissedTracerouteRequestId by remember { mutableStateOf(null) } val colorScheme = MaterialTheme.colorScheme - val scope = rememberCoroutineScope() LaunchedEffect(traceRouteResponse, dismissedTracerouteRequestId) { val response = traceRouteResponse @@ -87,17 +83,8 @@ fun TracerouteAlertHandler( dismissedTracerouteRequestId = response.requestId onNavigateToMap(response.destinationNodeNum, response.requestId, response.logUuid) } else { + uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) uiViewModel.clearTracerouteResponse() - // Post the error alert after the current alert is dismissed to avoid - // the wrapping dismissAlert() in AlertManager immediately clearing it. - @Suppress("TooGenericExceptionCaught") - scope.launch { - try { - uiViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) - } catch (e: Exception) { - Logger.e(e) { "[TracerouteAlertHandler] Failed to show error alert" } - } - } } }, dismissTextRes = Res.string.okay, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt index 4a710b0b3..b0e01011e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt @@ -59,7 +59,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -118,8 +117,8 @@ fun EmojiPickerDialog( onConfirm: (String) -> Unit, ) { val viewModel: EmojiPickerViewModel = koinViewModel() - var searchQuery by rememberSaveable { mutableStateOf("") } - var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) } + var searchQuery by remember { mutableStateOf("") } + var selectedCategoryIndex by remember { mutableStateOf(0) } val recentEmojis by remember(viewModel.customEmojiFrequency) { derivedStateOf { parseRecents(viewModel.customEmojiFrequency) } } @@ -428,7 +427,7 @@ private fun SectionHeader(title: String) { @OptIn(ExperimentalFoundationApi::class) @Composable private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (String) -> Unit) { - var showSkinTonePopup by rememberSaveable { mutableStateOf(false) } + var showSkinTonePopup by remember { mutableStateOf(false) } Box { Box( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt index 6bf669ab6..66060116f 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Device.kt @@ -25,7 +25,6 @@ import org.meshtastic.core.resources.ic_fingerprint import org.meshtastic.core.resources.ic_fork_left import org.meshtastic.core.resources.ic_home import org.meshtastic.core.resources.ic_icecream -import org.meshtastic.core.resources.ic_memory import org.meshtastic.core.resources.ic_military_tech import org.meshtastic.core.resources.ic_mountain_flag import org.meshtastic.core.resources.ic_my_location @@ -76,4 +75,4 @@ val MeshtasticIcons.DeviceNumbers: ImageVector val MeshtasticIcons.Android: ImageVector @Composable get() = vectorResource(Res.drawable.ic_android) val MeshtasticIcons.HardwareModel: ImageVector - @Composable get() = vectorResource(Res.drawable.ic_memory) + @Composable get() = vectorResource(Res.drawable.ic_router) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index 7e5271148..632c8abb4 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -40,7 +39,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -90,7 +88,7 @@ fun ScannedQrCodeDialog( onDismiss: () -> Unit, onConfirm: (ChannelSet) -> Unit, ) { - var shouldReplace by rememberSaveable { mutableStateOf(incoming.lora_config != null) } + var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) } val channelSet = remember(shouldReplace, channels, incoming) { @@ -242,33 +240,21 @@ fun ScannedQrCodeDialog( val unselectedColors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - val mediumHeight = ButtonDefaults.MediumContainerHeight - @OptIn(ExperimentalMaterial3ExpressiveApi::class) OutlinedButton( onClick = { shouldReplace = false }, - shapes = ButtonDefaults.shapesFor(mediumHeight), - modifier = Modifier.height(mediumHeight).weight(1f), + modifier = Modifier.height(48.dp).weight(1f), colors = if (!shouldReplace) selectedColors else unselectedColors, ) { - Text( - text = stringResource(Res.string.add), - style = ButtonDefaults.textStyleFor(mediumHeight), - ) + Text(text = stringResource(Res.string.add)) } - @OptIn(ExperimentalMaterial3ExpressiveApi::class) OutlinedButton( onClick = { shouldReplace = true }, - shapes = ButtonDefaults.shapesFor(mediumHeight), - modifier = Modifier.height(mediumHeight).weight(1f), + modifier = Modifier.height(48.dp).weight(1f), enabled = incoming.lora_config != null, colors = if (shouldReplace) selectedColors else unselectedColors, ) { - Text( - text = stringResource(Res.string.replace), - style = ButtonDefaults.textStyleFor(mediumHeight), - ) + Text(text = stringResource(Res.string.replace)) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index db23f1d77..2c10206aa 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -17,11 +17,12 @@ package org.meshtastic.core.ui.qr import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -39,7 +40,7 @@ class ScannedQrCodeViewModel( private val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { + fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -50,11 +51,11 @@ class ScannedQrCodeViewModel( } private fun setChannel(channel: Channel) { - safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } + viewModelScope.launch { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) private fun setConfig(config: Config) { - safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } + viewModelScope.launch { radioController.setLocalConfig(config) } } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt deleted file mode 100644 index cd68cd12c..000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ContrastLevel.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.ui.theme - -import androidx.compose.runtime.staticCompositionLocalOf - -/** - * Application-wide contrast level for accessibility. - * - * [STANDARD] keeps the default Material 3 color scheme. [MEDIUM] uses Material 3 medium-contrast color tokens and - * increases message bubble opacity. [HIGH] uses Material 3 high-contrast color tokens, forces `onSurface` text in - * message bubbles, and replaces translucent node-color fills with opaque theme surfaces plus accent borders. - */ -enum class ContrastLevel(val value: Int) { - STANDARD(0), - MEDIUM(1), - HIGH(2), - ; - - companion object { - fun fromValue(value: Int): ContrastLevel = entries.firstOrNull { it.value == value } ?: STANDARD - } -} - -/** - * Composition local providing the current [ContrastLevel]. - * - * Read by components that need to adapt their rendering for accessibility (e.g. message bubbles, signal indicators). - */ -val LocalContrastLevel = staticCompositionLocalOf { ContrastLevel.STANDARD } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt index d2047b603..240c01503 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt @@ -60,10 +60,6 @@ object GraphColors { val Lime = Color(0xFFCDDC39) val Indigo = Color(0xFF3F51B5) val DeepOrange = Color(0xFFFF5722) - val Magenta = Color(0xFFE040FB) - val SkyBlue = Color(0xFF03A9F4) - val Chartreuse = Color(0xFF76FF03) - val Coral = Color(0xFFFF6E40) } object StatusColors { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt index 07c6ab3ad..eb40222af 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/Theme.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("MatchingDeclarationName") +@file:Suppress("UnusedPrivateProperty") package org.meshtastic.core.ui.theme @@ -25,7 +25,6 @@ import androidx.compose.material3.MotionScheme.Companion.expressive import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color @@ -273,33 +272,19 @@ val unspecified_scheme = ColorFamily(Color.Unspecified, Color.Unspecified, Color fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, - contrastLevel: ContrastLevel = ContrastLevel.STANDARD, content: @Composable() () -> Unit, ) { - val dynamicScheme = - if (dynamicColor && contrastLevel == ContrastLevel.STANDARD) { - dynamicColorScheme(darkTheme) - } else { - null - } - val colorScheme = - dynamicScheme - ?: when (contrastLevel) { - ContrastLevel.MEDIUM -> if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme - ContrastLevel.HIGH -> if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme - else -> if (darkTheme) darkScheme else lightScheme - } + val dynamicScheme = if (dynamicColor) dynamicColorScheme(darkTheme) else null + val colorScheme = dynamicScheme ?: if (darkTheme) darkScheme else lightScheme - CompositionLocalProvider(LocalContrastLevel provides contrastLevel) { - MaterialExpressiveTheme( - colorScheme = colorScheme, - typography = AppTypography, - motionScheme = expressive(), - content = content, - ) - } + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = AppTypography, + motionScheme = expressive(), + content = content, + ) } const val MODE_DYNAMIC = 6969420 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt index d0901f0f9..5ac8eca5a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt @@ -27,24 +27,10 @@ import org.meshtastic.proto.Position * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded * inside another screen layout (e.g. the position-log adaptive layout). * - * Supports optional synchronized selection: - * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When - * non-null, the map should visually highlight the corresponding marker and center the camera on it. - * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so - * the host can synchronize the card list. - * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeTrackMapProvider = - compositionLocalOf< - @Composable ( - destNum: Int, - positions: List, - modifier: Modifier, - selectedPositionTime: Int?, - onPositionSelected: ((Int) -> Unit)?, - ) -> Unit, - > { - { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } + compositionLocalOf<@Composable (destNum: Int, positions: List, modifier: Modifier) -> Unit> { + { _, _, _ -> PlaceholderScreen("Position Track Map") } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index 9d3169c1a..d5910168b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -21,6 +21,7 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri /** Returns a function to open the platform's NFC settings. */ @Composable expect fun rememberOpenNfcSettings(): () -> Unit @@ -40,7 +41,7 @@ import org.meshtastic.core.common.util.CommonUri /** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */ @Composable expect fun rememberSaveFileLauncher( - onUriReceived: (CommonUri) -> Unit, + onUriReceived: (MeshtasticUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit /** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */ @@ -63,21 +64,3 @@ expect fun rememberSaveFileLauncher( /** Returns a launcher to open the platform's location settings. */ @Composable expect fun rememberOpenLocationSettings(): () -> Unit - -/** Returns a launcher to request Bluetooth scan + connect permissions. No-op on platforms without runtime BLE perms. */ -@Composable expect fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit - -/** Returns a launcher to request the POST_NOTIFICATIONS permission. No-op on platforms that don't require it. */ -@Composable -expect fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit - -/** - * Returns whether location permissions are currently granted. Always `true` on platforms without runtime permissions. - */ -@Composable expect fun isLocationPermissionGranted(): Boolean - -/** - * Returns whether GPS/location services are currently disabled at the system level. Always `false` on platforms where - * this concept doesn't apply. - */ -@Composable expect fun isGpsDisabled(): Boolean diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index edfda074c..95bf4365c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation3.runtime.NavKey import co.touchlab.kermit.Logger import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow @@ -35,7 +34,7 @@ import kotlinx.coroutines.flow.onEach import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.MyNodeInfo @@ -44,7 +43,6 @@ import org.meshtastic.core.model.TracerouteMapAvailability import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.model.util.dispatchMeshtasticUri -import org.meshtastic.core.navigation.DeepLinkRouter import org.meshtastic.core.repository.FirmwareReleaseRepository import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository @@ -86,7 +84,7 @@ class UIViewModel( val snackbarManager: SnackbarManager, ) : ViewModel() { - private val _navigationDeepLink = MutableSharedFlow>(replay = 1) + private val _navigationDeepLink = MutableSharedFlow>(replay = 1) val navigationDeepLink = _navigationDeepLink.asSharedFlow() /** @@ -98,16 +96,18 @@ class UIViewModel( * 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via * [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations. */ - fun handleDeepLink(uri: CommonUri, onInvalid: () -> Unit = {}) { + fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) { + val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString) + // Try navigation routing first - val navKeys = DeepLinkRouter.route(uri) + val navKeys = org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri) if (navKeys != null) { _navigationDeepLink.tryEmit(navKeys) return } // Fallback to channel/contact importing - uri.dispatchMeshtasticUri( + commonUri.dispatchMeshtasticUri( onContact = { setSharedContactRequested(it) }, onChannel = { setRequestChannelSet(it) }, onInvalid = onInvalid, @@ -115,7 +115,6 @@ class UIViewModel( } val theme: StateFlow = uiPrefs.theme - val contrastLevel: StateFlow = uiPrefs.contrastLevel val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition } @@ -237,12 +236,12 @@ class UIViewModel( _sharedContactRequested.value = contact } - /** Clears the pending shared contact request. */ + /** Called immediately after activity observes requestChannelUrl */ fun clearSharedContactRequested() { _sharedContactRequested.value = null } - /** Canonical app-level connection state, sourced from [ServiceRepository.connectionState]. */ + // Connection state to our radio device val connectionState get() = serviceRepository.connectionState @@ -256,7 +255,7 @@ class UIViewModel( val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() } - /** Clears the pending channel set import request. */ + /** Called immediately after activity observes requestChannelUrl */ fun clearRequestChannelUrl() { _requestChannelSet.value = null } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt index 905d50c2b..2201d70bd 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt @@ -14,30 +14,16 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon", "TooGenericExceptionCaught") +@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon") package org.meshtastic.core.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.UiText -import org.meshtastic.core.resources.unknown_error -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -54,82 +40,3 @@ fun Flow.stateInWhileSubscribed(initialValue: T, stopTimeout: Duration = started = SharingStarted.WhileSubscribed(stopTimeoutMillis = stopTimeout.inWholeMilliseconds), initialValue = initialValue, ) - -// --------------------------------------------------------------------------- -// UiState: shared Loading / Content / Error wrapper -// --------------------------------------------------------------------------- - -/** - * Lightweight tri-state wrapper for UI data. Prefer this over bare nullable initial values when the UI needs to - * distinguish "still loading" from "genuinely empty." - */ -sealed interface UiState { - /** Data has not yet arrived. */ - data object Loading : UiState - - /** Data is available. */ - data class Content(val data: T) : UiState - - /** An error occurred while loading. */ - data class Error(val message: UiText) : UiState -} - -/** Returns the [Content] data, or `null` if this state is [Loading] or [Error]. */ -fun UiState.dataOrNull(): T? = (this as? UiState.Content)?.data - -/** - * Wraps this [Flow] into a `StateFlow>`, emitting [UiState.Loading] until the first value, then - * [UiState.Content] for each emission. Upstream errors are caught and mapped to [UiState.Error]. - */ -context(viewModel: ViewModel) -fun Flow.asUiState(stopTimeout: Duration = 5.seconds): StateFlow> = - this.map> { UiState.Content(it) } - .onStart { emit(UiState.Loading) } - .catch { e -> - val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error) - emit(UiState.Error(message)) - } - .stateInWhileSubscribed(initialValue = UiState.Loading, stopTimeout = stopTimeout) - -// --------------------------------------------------------------------------- -// safeLaunch: CancellationException-safe coroutine launcher with error routing -// --------------------------------------------------------------------------- - -/** - * Launches a coroutine in [viewModelScope] that catches all exceptions except [CancellationException]. Non-cancellation - * errors are logged and emitted to [errorEvents] (if provided) for one-shot UI consumption (e.g. snackbar / toast). - * - * @param context optional [CoroutineContext] element (typically a dispatcher) merged into the launch. Defaults to - * [EmptyCoroutineContext], inheriting [viewModelScope]'s dispatcher. - * - * ``` - * // In a ViewModel: - * safeLaunch(errorEvents = _errors) { - * repository.saveData(data) - * } - * ``` - */ -context(viewModel: ViewModel) -fun safeLaunch( - context: CoroutineContext = EmptyCoroutineContext, - errorEvents: MutableSharedFlow? = null, - tag: String? = null, - block: suspend CoroutineScope.() -> Unit, -): Job = viewModel.viewModelScope.launch(context) { - try { - block() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - val label = tag ?: "safeLaunch" - Logger.e(e) { "[$label] Unhandled exception" } - val message = e.message?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.unknown_error) - errorEvents?.tryEmit(message) - } -} - -/** - * Creates and returns a [MutableSharedFlow] intended for one-shot error events. Expose as `SharedFlow` via - * [asSharedFlow] in the ViewModel, and collect in the UI to show snackbars or toasts. - */ -fun errorEventFlow(): MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt index db0560e90..d221aeb39 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerTest.kt @@ -68,27 +68,4 @@ class AlertManagerTest { assertEquals(true, dismissClicked) assertNull(alertManager.currentAlert.value) } - - @Test - fun showAlert_inside_onConfirm_is_dismissed_by_wrapping_dismissAlert() { - // Documents the known race condition: AlertManager wraps onConfirm to call - // dismissAlert() AFTER the user callback, so a showAlert() inside onConfirm - // gets immediately cleared. Callers must defer via launch {} to work around this. - alertManager.showAlert( - title = "First", - onConfirm = { - // This simulates an error path where onConfirm shows a follow-up alert - alertManager.showAlert(title = "Second", message = "Error details") - }, - ) - - // Trigger the wrapped onConfirm (user callback + dismissAlert) - alertManager.currentAlert.value?.onConfirm?.invoke() - - // The second alert is wiped by dismissAlert() — currentAlert is null - assertNull( - alertManager.currentAlert.value, - "showAlert inside onConfirm is cleared by the wrapping dismissAlert; callers must defer via launch {}", - ) - } } diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt index ebe791f8e..590bd1fe9 100644 --- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt +++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLinkStyles import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri actual fun createClipEntry(text: String, label: String): ClipEntry = throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub") @@ -40,7 +41,7 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A @Composable actual fun rememberSaveFileLauncher( - onUriReceived: (CommonUri) -> Unit, + onUriReceived: (MeshtasticUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> } @Composable @@ -56,13 +57,4 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT @Composable actual fun rememberOpenLocationSettings(): () -> Unit = {} -@Composable actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {} - -@Composable -actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {} - -@Composable actual fun isLocationPermissionGranted(): Boolean = true - -@Composable actual fun isGpsDisabled(): Boolean = false - @Composable actual fun SetScreenBrightness(brightness: Float) {} diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index 165262170..22f84b217 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.ui.component import androidx.compose.runtime.Composable -import org.meshtastic.core.common.util.nowMillis -/** JVM implementation — returns the current epoch millis (no lifecycle-based updates on Desktop). */ -@Composable actual fun rememberTimeTickWithLifecycle(): Long = nowMillis +/** JVM implementation — returns System.currentTimeMillis() (no lifecycle-based updates on Desktop). */ +@Composable actual fun rememberTimeTickWithLifecycle(): Long = System.currentTimeMillis() diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index a938f92ea..0e06fc398 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -20,10 +20,11 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.common.util.MeshtasticUri import java.awt.Desktop import java.awt.FileDialog import java.awt.Frame @@ -60,7 +61,7 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url -> /** JVM — Opens a native file dialog to save a file. */ @Composable actual fun rememberSaveFileLauncher( - onUriReceived: (CommonUri) -> Unit, + onUriReceived: (MeshtasticUri) -> Unit, ): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ -> val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE) dialog.file = defaultFilename @@ -69,7 +70,7 @@ actual fun rememberSaveFileLauncher( val dir = dialog.directory if (file != null && dir != null) { val path = File(dir, file) - onUriReceived(CommonUri.parse(path.toURI().toString())) + onUriReceived(MeshtasticUri(path.toURI().toString())) } } @@ -82,14 +83,14 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT val dir = dialog.directory if (file != null && dir != null) { val path = File(dir, file) - onUriReceived(CommonUri.parse(path.toURI().toString())) + onUriReceived(CommonUri(path.toURI())) } } /** JVM — Reads text from a file URI. */ @Composable -actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> - withContext(ioDispatcher) { +actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? = { uri, maxChars -> + withContext(Dispatchers.IO) { @Suppress("TooGenericExceptionCaught") try { val file = File(URI(uri.toString())) @@ -129,19 +130,3 @@ actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () @Composable actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } } - -/** JVM no-op — Desktop does not require runtime Bluetooth permissions. */ -@Composable -actual fun rememberRequestBluetoothPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { onGranted() } - -/** JVM no-op — Desktop does not require runtime notification permissions. */ -@Composable -actual fun rememberRequestNotificationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = { - onGranted() -} - -/** JVM — location permission is always considered granted on Desktop. */ -@Composable actual fun isLocationPermissionGranted(): Boolean = true - -/** JVM — GPS is never disabled on Desktop (concept doesn't apply). */ -@Composable actual fun isGpsDisabled(): Boolean = false diff --git a/desktop/README.md b/desktop/README.md index 975cd59e2..129f49e94 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -25,18 +25,14 @@ A Compose Desktop application target — the first full non-Android target for t ## ProGuard / Minification -Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source. Rules are aligned with the Android R8 rules in `app/proguard-rules.pro` — both targets share the same anti-class-merging philosophy. +Release builds use ProGuard for tree-shaking (unused code removal), significantly reducing distribution size. Obfuscation is disabled since the project is open-source. **Configuration:** - `build.gradle.kts` — `buildTypes.release.proguard` block enables ProGuard with `optimize.set(true)` and `obfuscate.set(false)`. -- `proguard-rules.pro` — Keep-rules for reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP `androidx.room3`, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources) and an anti-merge rule for Compose animation classes. - -**Key rules:** -- **Compose animation anti-merge** (`-keep class androidx.compose.animation.** { *; }`) — Prevents ProGuard's optimizer from incorrectly tree-shaking or merging animation class hierarchies (e.g. `EnterTransition`/`ExitTransition` into `*Impl`), which causes animations to silently snap. Same rule as Android. -- **Room KMP** — Uses `androidx.room3` package path (Room KMP 3.x). +- `proguard-rules.pro` — Comprehensive keep-rules for all reflection/JNI-sensitive dependencies (Koin, kotlinx-serialization, Wire protobuf, Room KMP, Ktor, Kable BLE, Coil, SQLite JNI, Compose Multiplatform resources). **Troubleshooting ProGuard issues:** -- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro` **and** the corresponding rule in `app/proguard-rules.pro` to keep both targets aligned. +- If the release build crashes at runtime with `ClassNotFoundException` or `NoSuchMethodError`, a library is loading classes via reflection that ProGuard stripped. Add a `-keep` rule in `proguard-rules.pro`. - To debug which classes ProGuard removes, temporarily add `-printusage proguard-usage.txt` to the rules file and inspect the output in `desktop/proguard-usage.txt`. - To see the full mapping of optimizations applied, add `-printseeds proguard-seeds.txt`. - Run `./gradlew :desktop:runRelease` for a quick smoke-test of the minified app before packaging. diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 58caf800b..6c4239a0f 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -20,8 +20,6 @@ import com.mikepenz.aboutlibraries.plugin.DuplicateRule import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.meshtastic.buildlogic.GitVersionValueSource -import org.meshtastic.buildlogic.configProperties plugins { alias(libs.plugins.kotlin.jvm) @@ -34,71 +32,6 @@ plugins { alias(libs.plugins.aboutlibraries) } -// ── Version resolution (mirrors app/build.gradle.kts) ──────────────────────── -val gitVersionProvider = providers.of(GitVersionValueSource::class.java) {} - -val vcOffset = configProperties.getProperty("VERSION_CODE_OFFSET")?.toInt() ?: 0 -val resolvedVersionCode: Int = - project.findProperty("android.injected.version.code")?.toString()?.toInt() - ?: System.getenv("VERSION_CODE")?.toInt() - ?: (gitVersionProvider.get().toInt() + vcOffset) -val resolvedVersionName: String = - project.findProperty("android.injected.version.name")?.toString() - ?: project.findProperty("appVersionName")?.toString() - ?: System.getenv("VERSION_NAME") - ?: configProperties.getProperty("VERSION_NAME_BASE") - ?: "1.0.0" -val resolvedIsDebug: Boolean = project.findProperty("desktop.release")?.toString()?.toBoolean()?.not() ?: true -val resolvedMinFwVersion: String = configProperties.getProperty("MIN_FW_VERSION") ?: "" -val resolvedAbsMinFwVersion: String = configProperties.getProperty("ABS_MIN_FW_VERSION") ?: "" - -// ── Generate DesktopBuildConfig ────────────────────────────────────────────── -// Mirrors AGP's BuildConfig for Android so the desktop runtime has access to the -// same version metadata without hardcoding. -// Uses an abstract task with typed properties so the configuration cache can -// serialise it without capturing build-script object references. -@CacheableTask -abstract class GenerateBuildConfigTask : DefaultTask() { - @get:Input abstract val content: Property - - @get:OutputDirectory abstract val outputDir: DirectoryProperty - - @TaskAction - fun generate() { - val dir = outputDir.get().asFile - dir.mkdirs() - dir.resolve("DesktopBuildConfig.kt").writeText(content.get()) - } -} - -val buildConfigOutputDir = layout.buildDirectory.dir("generated/buildconfig") - -val generateBuildConfig = - tasks.register("generateDesktopBuildConfig") { - content.set( - """ - |package org.meshtastic.desktop - | - |/** - | * Auto-generated build configuration for Meshtastic Desktop. - | * Do not edit — values are derived from config.properties and git at build time. - | */ - |object DesktopBuildConfig { - | const val VERSION_CODE: Int = $resolvedVersionCode - | const val VERSION_NAME: String = "$resolvedVersionName" - | const val IS_DEBUG: Boolean = $resolvedIsDebug - | const val APPLICATION_ID: String = "org.meshtastic.desktop" - | const val MIN_FW_VERSION: String = "$resolvedMinFwVersion" - | const val ABS_MIN_FW_VERSION: String = "$resolvedAbsMinFwVersion" - |} - """ - .trimMargin(), - ) - outputDir.set(buildConfigOutputDir.map { it.dir("org/meshtastic/desktop") }) - } - -sourceSets.main { kotlin.srcDir(generateBuildConfig.map { buildConfigOutputDir }) } - kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(21)) @@ -127,10 +60,7 @@ compose.desktop { isEnabled.set(true) obfuscate.set(false) // Open-source project — obfuscation adds no value optimize.set(true) - configurationFiles.from( - rootProject.file("config/proguard/shared-rules.pro"), - project.file("proguard-rules.pro"), - ) + configurationFiles.from(project.file("proguard-rules.pro")) } nativeDistributions { @@ -140,7 +70,6 @@ compose.desktop { // jdeps might miss some of these if they are loaded via reflection or JNI. modules( "java.net.http", // Ktor Java client - "jdk.accessibility", // Java Access Bridge for screen readers (JAWS, NVDA, VoiceOver) "jdk.crypto.ec", // Required for SSL/TLS HTTPS requests "jdk.unsupported", // sun.misc.Unsafe used by Coroutines & Okio "java.sql", // Sometimes required by SQLite JNI @@ -161,27 +90,11 @@ compose.desktop { iconFile.set(project.file("src/main/resources/icon.icns")) minimumSystemVersion = "12.0" bundleID = "org.meshtastic.desktop" - entitlementsFile.set(project.file("entitlements.plist")) infoPlist { extraKeysRawXml = """ - NSBluetoothAlwaysUsageDescription - Meshtastic uses Bluetooth to communicate with your Meshtastic radio device. - NSLocalNetworkUsageDescription - Meshtastic uses your local network to discover Meshtastic devices connected via WiFi. NSUserNotificationAlertStyle alert - CFBundleURLTypes - - - CFBundleURLName - Meshtastic deep link - CFBundleURLSchemes - - meshtastic - - - """ .trimIndent() } @@ -212,9 +125,14 @@ compose.desktop { else -> targetFormats(TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage) } - // Reuse the resolved version from the top of this script (mirrors app/build.gradle.kts). - // Native installers require strict numeric semantic versions (X.Y.Z) without suffixes. - val sanitizedVersion = Regex("^\\d+\\.\\d+\\.\\d+").find(resolvedVersionName)?.value ?: "1.0.0" + // Read version from project properties (passed by CI) or default to 1.0.0 + // Native installers require strict numeric semantic versions (X.Y.Z) without suffixes + val rawVersion = + project.findProperty("android.injected.version.name")?.toString() + ?: project.findProperty("appVersionName")?.toString() + ?: System.getenv("VERSION_NAME") + ?: "1.0.0" + val sanitizedVersion = Regex("^\\d+\\.\\d+\\.\\d+").find(rawVersion)?.value ?: "1.0.0" packageVersion = sanitizedVersion description = "Meshtastic Desktop Application" @@ -264,7 +182,6 @@ dependencies { // Compose Desktop implementation(compose.desktop.currentOs) - implementation(libs.compose.multiplatform.animation) implementation(libs.compose.multiplatform.material3) implementation(libs.compose.multiplatform.runtime) implementation(libs.compose.multiplatform.foundation) @@ -294,7 +211,6 @@ dependencies { // Ktor HttpClient (Java engine for JVM/Desktop) implementation(libs.ktor.client.java) implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.logging) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.androidx.paging.common) diff --git a/desktop/entitlements.plist b/desktop/entitlements.plist deleted file mode 100644 index f799a66e9..000000000 --- a/desktop/entitlements.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.disable-library-validation - - com.apple.security.device.bluetooth - - - diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index 280214b2e..a73c347d1 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -4,57 +4,201 @@ # Open-source project: we rely on tree-shaking (unused code removal) for size # reduction. Obfuscation is disabled in build.gradle.kts (obfuscate.set(false)). # -# 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. +# 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. # ============================================================================ # ---- General ---------------------------------------------------------------- +# Preserve line numbers for meaningful stack traces +-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions + # Suppress notes about duplicate resource files (common in fat JARs) -dontnote ** -# Disable ProGuard optimization passes. Tree-shaking (unused code removal) still -# runs — only method-body rewrites and call-site transformations are suppressed. -# -# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on -# Composer.() and ComposerImpl.(), plus -assumevalues on -# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives -# let the optimizer rewrite *call sites* (class-init triggers, flag reads) even -# when the target classes are preserved by -keep rules. The result is that the -# Compose recomposer/frame-clock/animation state machines silently freeze on -# their first frame in release builds. -dontoptimize is the only directive that -# disables processing of -assumenosideeffects/-assumevalues. The desktop compose -# build sets optimize.set(true), so this applies here as well as to R8. See #5146. --dontoptimize - # Do not parse/rewrite Kotlin metadata during shrinking/optimization. # ProGuard's KotlinShrinker cannot handle the metadata produced by Compose # Multiplatform 1.11.x + Kotlin 2.3.x, causing a NullPointerException. # Since we disable obfuscation (class names remain stable), metadata references # stay valid and do not need rewriting. The annotations themselves are preserved # by -keepattributes *Annotation*. -# -# NOTE: -dontprocesskotlinmetadata is a ProGuard-only directive; R8 does not -# recognize it, which is why it lives in the desktop-only file. -dontprocesskotlinmetadata # ---- Entry point ------------------------------------------------------------ -keep class org.meshtastic.desktop.MainKt { *; } -# ---- Ktor Java engine (desktop-only; Android uses OkHttp) ------------------- -# io.ktor.client.engine.java ships consumer rules; the shared -# HttpClientEngineFactory ServiceLoader keep in shared-rules.pro covers the -# reflective discovery path. +# ---- Kotlin / Coroutines --------------------------------------------------- -# ---- Meshtastic desktop host shell ------------------------------------------ +# Keep Kotlin metadata for reflection-dependent libraries +-keep class kotlin.Metadata { *; } +-keep class kotlin.reflect.** { *; } + +# Coroutines internals +-dontwarn kotlinx.coroutines.** +-keep class kotlinx.coroutines.** { *; } +-keep class kotlin.coroutines.Continuation { *; } + +# ---- Koin DI (reflection-based injection) ----------------------------------- + +# Koin core — uses reflection to instantiate definitions +-keep class org.koin.** { *; } +-dontwarn org.koin.** + +# Keep all Koin-annotated @Module / @ComponentScan classes and their generated +# counterparts so Koin K2 plugin output survives tree-shaking. +-keep @org.koin.core.annotation.Module class * { *; } +-keep @org.koin.core.annotation.ComponentScan class * { *; } +-keep @org.koin.core.annotation.Single class * { *; } +-keep @org.koin.core.annotation.Factory class * { *; } + +# Generated Koin module extensions (K2 plugin output) +-keep class org.meshtastic.**.di.** { *; } + +# ---- kotlinx-serialization -------------------------------------------------- + +# The serialization plugin generates companion $serializer classes and +# serializer() factory methods that are invoked reflectively. +-keepattributes RuntimeVisibleAnnotations +-keep class kotlinx.serialization.** { *; } +-dontwarn kotlinx.serialization.** + +# Keep @Serializable classes and their generated serializers +-keepclassmembers @kotlinx.serialization.Serializable class ** { + # Companion object that holds the serializer() factory + static ** Companion; + kotlinx.serialization.KSerializer serializer(...); +} +-keepclassmembers class **.$serializer { *; } +-keep class **.$serializer { *; } +-keepclasseswithmembers class ** { + kotlinx.serialization.KSerializer serializer(...); +} + +# ---- Wire protobuf ---------------------------------------------------------- + +# Wire generates ADAPTER companion objects accessed via reflection +-keep class com.squareup.wire.** { *; } +-dontwarn com.squareup.wire.** + +# All generated proto message classes +-keep class org.meshtastic.proto.** { *; } +-keep class meshtastic.** { *; } + +# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs) +-dontwarn android.os.Parcel** +-dontwarn android.os.Parcelable** + +# ---- Room KMP --------------------------------------------------------------- + +# Preserve generated database constructors (required for Room's reflective init) +-keep class * extends androidx.room3.RoomDatabase { (); } +-keep class * implements androidx.room3.RoomDatabaseConstructor { *; } + +# Keep the expect/actual MeshtasticDatabaseConstructor +-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; } +-keep class org.meshtastic.core.database.MeshtasticDatabase { *; } + +# Room DAOs — Room generates implementations at compile time; keep interfaces +-keep class org.meshtastic.core.database.dao.** { *; } + +# Room Entities — accessed via reflection for column mapping +-keep class org.meshtastic.core.database.entity.** { *; } + +# Room TypeConverters — invoked reflectively +-keep class org.meshtastic.core.database.Converters { *; } + +# Room generated _Impl classes +-keep class **_Impl { *; } + +# ---- SQLite bundled (JNI) --------------------------------------------------- + +-keep class androidx.sqlite.** { *; } +-dontwarn androidx.sqlite.** + +# ---- Ktor (Java engine + ServiceLoader + content negotiation) --------------- + +# Ktor uses ServiceLoader and reflection for engine/plugin discovery +-keep class io.ktor.** { *; } +-dontwarn io.ktor.** + +# Keep ServiceLoader metadata files +-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; } + +# Java HTTP client engine +-keep class io.ktor.client.engine.java.** { *; } + +# ---- Coil (image loading) --------------------------------------------------- + +-keep class coil3.** { *; } +-dontwarn coil3.** + +# ---- Kable BLE -------------------------------------------------------------- + +-keep class com.juul.kable.** { *; } +-dontwarn com.juul.kable.** + +# ---- Compose Multiplatform resources ---------------------------------------- + +# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.) +-keep class org.jetbrains.compose.resources.** { *; } +-keep class org.meshtastic.core.resources.** { *; } + +# ---- AboutLibraries --------------------------------------------------------- + +-keep class com.mikepenz.aboutlibraries.** { *; } +-dontwarn com.mikepenz.aboutlibraries.** + +# ---- Multiplatform Markdown Renderer ---------------------------------------- + +-keep class com.mikepenz.markdown.** { *; } +-dontwarn com.mikepenz.markdown.** + +# ---- QR Code Kotlin --------------------------------------------------------- + +-keep class io.github.g0dkar.qrcode.** { *; } +-dontwarn io.github.g0dkar.qrcode.** +-keep class qrcode.** { *; } +-dontwarn qrcode.** + +# ---- Kermit logging ---------------------------------------------------------- + +-keep class co.touchlab.kermit.** { *; } +-dontwarn co.touchlab.kermit.** + +# ---- Okio ------------------------------------------------------------------- + +-dontwarn okio.** +-keep class okio.** { *; } + +# ---- DataStore -------------------------------------------------------------- + +-keep class androidx.datastore.** { *; } +-dontwarn androidx.datastore.** + +# ---- Paging ----------------------------------------------------------------- + +-keep class androidx.paging.** { *; } +-dontwarn androidx.paging.** + +# ---- Lifecycle / Navigation / ViewModel (JetBrains forks) ------------------- + +-keep class androidx.lifecycle.** { *; } +-keep class androidx.navigation3.** { *; } +-dontwarn androidx.lifecycle.** +-dontwarn androidx.navigation3.** + +# ---- Meshtastic application code -------------------------------------------- # Keep all desktop module classes (thin host shell — not worth tree-shaking) -keep class org.meshtastic.desktop.** { *; } +# Core model classes (used in serialization, Room, and Koin injection) +-keep class org.meshtastic.core.model.** { *; } + # ---- JVM runtime suppression ------------------------------------------------ -dontwarn java.lang.reflect.** diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index e3c7f8b19..26fa16f6e 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.desktop -import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -26,22 +25,15 @@ import org.meshtastic.core.repository.NotificationPrefs import androidx.compose.ui.window.Notification as ComposeNotification /** - * Desktop notification manager that bridges domain [Notification] objects to Compose Desktop tray notifications. - * - * Notifications are emitted via [notifications] and collected by the tray composable in [Main.kt]. Respects user - * preferences for message, node-event, and low-battery categories. - * - * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the - * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. + * Desktop notification manager. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid + * double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. */ class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager { init { - Logger.i { "DesktopNotificationManager initialized" } + co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" } } private val _notifications = MutableSharedFlow(extraBufferCapacity = 10) - - /** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */ val notifications: SharedFlow = _notifications.asSharedFlow() override fun dispatch(notification: Notification) { @@ -54,7 +46,9 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific Notification.Category.Service -> true } - Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" } + co.touchlab.kermit.Logger.d { + "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" + } if (!enabled) return @@ -67,14 +61,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific } val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType)) - Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" } + co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" } } override fun cancel(id: Int) { - // Desktop tray notifications cannot be cancelled once sent via TrayState. + // Desktop Tray notifications cannot be cancelled once sent via TrayState } override fun cancelAll() { - // Desktop tray notifications cannot be cleared once sent via TrayState. + // Desktop Tray notifications cannot be cleared once sent via TrayState } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 026f0a100..0a450c007 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -18,6 +18,7 @@ package org.meshtastic.desktop import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -26,22 +27,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberWindowState @@ -51,30 +52,20 @@ import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory import coil3.disk.DiskCache import coil3.memory.MemoryCache -import coil3.network.DeDupeConcurrentRequestStrategy import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder -import coil3.util.DebugLogger import io.ktor.client.HttpClient import kotlinx.coroutines.flow.first import okio.Path.Companion.toPath -import org.jetbrains.compose.resources.decodeToSvgPainter -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject +import org.jetbrains.skia.Image import org.koin.core.context.startKoin -import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.database.desktopDataDir -import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.navigation.rememberMultiBackstack import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.desktop_tray_quit -import org.meshtastic.core.resources.desktop_tray_show -import org.meshtastic.core.resources.desktop_tray_tooltip import org.meshtastic.core.service.MeshServiceOrchestrator import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.viewmodel.UIViewModel @@ -84,51 +75,33 @@ import org.meshtastic.desktop.di.desktopPlatformModule import org.meshtastic.desktop.ui.DesktopMainScreen import java.awt.Desktop import java.util.Locale -import coil3.util.Logger as CoilLogger /** Meshtastic Desktop — the first non-Android target for the shared KMP module graph. */ +private val LocalAppLocale = staticCompositionLocalOf { "" } + private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB -/** - * Loads an SVG from JVM classpath resources and returns a [Painter]. - * - * Uses the CMP 1.11 `decodeToSvgPainter` extension which replaces the deprecated `useResource`/`loadSvgPainter` pair. - * The SVG bytes are read from the classpath because CMP `composeResources/` only supports XML vector drawables and - * raster images — not raw SVGs. Since the desktop module is a JVM-only host shell, classpath resource access is safe. - */ @Composable -private fun svgPainterResource(path: String, density: Density): Painter = remember(path, density) { - val classLoader = - requireNotNull(Thread.currentThread().contextClassLoader) { - "Missing context class loader while loading resource: $path" +private fun classpathPainterResource(path: String): Painter { + val bitmap: ImageBitmap = + remember(path) { + val bytes = Thread.currentThread().contextClassLoader!!.getResourceAsStream(path)!!.readAllBytes() + Image.makeFromEncoded(bytes).toComposeImageBitmap() } - val bytes = - requireNotNull(classLoader.getResourceAsStream(path)) { "Missing classpath resource: $path" } - .use { it.readAllBytes() } - bytes.decodeToSvgPainter(density) + return remember(bitmap) { BitmapPainter(bitmap) } } +@Suppress("LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalCoilApi::class) fun main(args: Array) = application(exitProcessOnExit = false) { - val koinApp = remember { - Logger.i { "Meshtastic Desktop — Starting" } - startKoin { modules(desktopPlatformModule(), desktopModule()) } - } + Logger.i { "Meshtastic Desktop — Starting" } + + val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } } val systemLocale = remember { Locale.getDefault() } val uiViewModel = remember { koinApp.koin.get() } val httpClient = remember { koinApp.koin.get() } - DeepLinkHandler(args, uiViewModel) - MeshServiceLifecycle() - ThemeAndLocaleProvider(uiViewModel) -} - -// ----- Deep link handling ----- - -/** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */ -@Composable -private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: UIViewModel) { LaunchedEffect(args) { args.forEach { arg -> if ( @@ -136,7 +109,7 @@ private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: U arg.startsWith("http://meshtastic.org") || arg.startsWith("https://meshtastic.org") ) { - uiViewModel.handleDeepLink(CommonUri.parse(arg)) { + uiViewModel.handleDeepLink(MeshtasticUri(arg)) { Logger.e { "Invalid Meshtastic URI passed via args: $arg" } } } @@ -147,36 +120,21 @@ private fun ApplicationScope.DeepLinkHandler(args: Array, uiViewModel: U if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) { Desktop.getDesktop().setOpenURIHandler { event -> val uriStr = event.uri.toString() - uiViewModel.handleDeepLink(CommonUri.parse(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } + uiViewModel.handleDeepLink(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } } } } } -} -// ----- Mesh service lifecycle ----- - -/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal. */ -@Composable -private fun MeshServiceLifecycle() { - val meshServiceController = koinInject() + val meshServiceController = remember { koinApp.koin.get() } DisposableEffect(Unit) { meshServiceController.start() onDispose { meshServiceController.stop() } } -} -// ----- Theme, locale, and application shell ----- - -/** Resolves the user's theme/locale preferences and renders the full application UI. */ -@Composable -@OptIn(ExperimentalCoilApi::class) -private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { - val systemLocale = remember { Locale.getDefault() } - val uiPrefs = koinInject() + val uiPrefs = remember { koinApp.koin.get() } val themePref by uiPrefs.theme.collectAsState(initial = -1) val localePref by uiPrefs.locale.collectAsState(initial = "") - val contrastLevelValue by uiPrefs.contrastLevel.collectAsState(initial = 0) - val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) + Locale.setDefault(localePref.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag) ?: systemLocale) val isDarkTheme = @@ -186,63 +144,25 @@ private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) { else -> isSystemInDarkTheme() } - MeshtasticDesktopApp(uiViewModel, isDarkTheme, contrastLevel) -} - -// ----- Application chrome (tray, window, navigation) ----- - -/** Composes the system tray, window, and Coil image loader. */ -@Composable -@OptIn(ExperimentalCoilApi::class) -private fun ApplicationScope.MeshtasticDesktopApp( - uiViewModel: UIViewModel, - isDarkTheme: Boolean, - contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, -) { var isAppVisible by remember { mutableStateOf(true) } var isWindowReady by remember { mutableStateOf(false) } val trayState = rememberTrayState() - val density = LocalDensity.current - val appIcon = svgPainterResource("tray_icon_black.svg", density) + val appIcon = classpathPainterResource("icon.png") + @Suppress("DEPRECATION") val trayIcon = - svgPainterResource(if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", density) + androidx.compose.ui.res.painterResource( + if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", + ) - val notificationManager = koinInject() - val desktopPrefs = koinInject() + val notificationManager = remember { koinApp.koin.get() } + val desktopPrefs = remember { koinApp.koin.get() } val windowState = rememberWindowState() LaunchedEffect(Unit) { notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) } } - WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true } - - Tray( - state = trayState, - icon = trayIcon, - tooltip = stringResource(Res.string.desktop_tray_tooltip), - onAction = { isAppVisible = true }, - menu = { - Item(stringResource(Res.string.desktop_tray_show), onClick = { isAppVisible = true }) - Item(stringResource(Res.string.desktop_tray_quit), onClick = ::exitApplication) - }, - ) - - if (isWindowReady && isAppVisible) { - MeshtasticWindow(uiViewModel, isDarkTheme, contrastLevel, appIcon, windowState) { isAppVisible = false } - } -} - -// ----- Window bounds persistence ----- - -/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */ -@Composable -private fun WindowBoundsManager( - desktopPrefs: DesktopPreferencesDataSource, - windowState: WindowState, - onReady: () -> Unit, -) { LaunchedEffect(Unit) { val initialWidth = desktopPrefs.windowWidth.first() val initialHeight = desktopPrefs.windowHeight.first() @@ -257,7 +177,7 @@ private fun WindowBoundsManager( WindowPosition(Alignment.Center) } - onReady() + isWindowReady = true snapshotFlow { val x = if (windowState.position.isSpecified) windowState.position.x.value else Float.NaN @@ -268,107 +188,86 @@ private fun WindowBoundsManager( desktopPrefs.setWindowBounds(width = bounds[0], height = bounds[1], x = bounds[2], y = bounds[3]) } } -} -// ----- Main window with keyboard shortcuts and Coil ----- + Tray( + state = trayState, + icon = trayIcon, + tooltip = "Meshtastic Desktop", + onAction = { isAppVisible = true }, + menu = { + Item("Show Meshtastic", onClick = { isAppVisible = true }) + Item("Quit", onClick = ::exitApplication) + }, + ) -/** Renders the main application window with keyboard shortcuts, Coil image loading, and the Compose UI tree. */ -@Composable -@OptIn(ExperimentalCoilApi::class) -private fun ApplicationScope.MeshtasticWindow( - uiViewModel: UIViewModel, - isDarkTheme: Boolean, - contrastLevel: org.meshtastic.core.ui.theme.ContrastLevel, - appIcon: Painter, - windowState: WindowState, - onCloseRequest: () -> Unit, -) { - val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) + if (isWindowReady && isAppVisible) { + val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route) + val backStack = multiBackstack.activeBackStack - Window( - onCloseRequest = onCloseRequest, - title = "Meshtastic Desktop", - icon = appIcon, - state = windowState, - onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) }, - ) { - CoilImageLoaderSetup() - AppTheme(darkTheme = isDarkTheme, contrastLevel = contrastLevel) { - DesktopMainScreen(uiViewModel, multiBackstack) - } - } -} - -/** Configures the Coil singleton [ImageLoader] with Ktor networking, SVG decoding, and caching. */ -@Composable -@OptIn(ExperimentalCoilApi::class) -private fun CoilImageLoaderSetup() { - val httpClient = koinInject() - val buildConfigProvider = koinInject() - - setSingletonImageLoaderFactory { context -> - val cacheDir = desktopDataDir() + "/image_cache_v3" - ImageLoader.Builder(context) - .components { - add( - KtorNetworkFetcherFactory( - httpClient = httpClient, - concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(), - ), - ) - // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts - // that show up as solid/black hardware images. - add(SvgDecoder.Factory(renderToBitmap = true)) + Window( + onCloseRequest = { isAppVisible = false }, + title = "Meshtastic Desktop", + icon = appIcon, + state = windowState, + onPreviewKeyEvent = { event -> + if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false + when { + event.key == Key.Q -> { + exitApplication() + true + } + event.key == Key.Comma -> { + if ( + TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull()) + ) { + multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) + } + true + } + event.key == Key.One -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) + true + } + event.key == Key.Two -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) + true + } + event.key == Key.Three -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) + true + } + event.key == Key.Four -> { + multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) + true + } + event.key == Key.Slash -> { + backStack.add(SettingsRoute.About) + true + } + else -> false + } + }, + ) { + setSingletonImageLoaderFactory { context -> + val cacheDir = desktopDataDir() + "/image_cache_v3" + ImageLoader.Builder(context) + .components { + add(KtorNetworkFetcherFactory(httpClient = httpClient)) + // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts + // that show up as solid/black hardware images. + add(SvgDecoder.Factory(renderToBitmap = true)) + } + .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } + .diskCache { + DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() + } + .crossfade(true) + .build() } - .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() } - .diskCache { DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() } - .logger(if (buildConfigProvider.isDebug) DebugLogger(minLevel = CoilLogger.Level.Verbose) else null) - .crossfade(true) - .build() - } -} -// ----- Keyboard shortcuts ----- - -/** Handles Cmd-key shortcuts. Returns `true` if the event was consumed. */ -private fun handleKeyboardShortcut( - event: androidx.compose.ui.input.key.KeyEvent, - multiBackstack: MultiBackstack, - exitApplication: () -> Unit, -): Boolean { - if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return false - val backStack = multiBackstack.activeBackStack - return when (event.key) { - Key.Q -> { - exitApplication() - true - } - Key.Comma -> { - if (TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())) { - multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route) + CompositionLocalProvider(LocalAppLocale provides localePref) { + AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } } - true } - Key.One -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route) - true - } - Key.Two -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route) - true - } - Key.Three -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Map.route) - true - } - Key.Four -> { - multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route) - true - } - Key.Slash -> { - backStack.add(SettingsRoute.About) - true - } - else -> false } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt index 6dd562bd4..9af34f28d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt @@ -21,6 +21,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -29,21 +30,16 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers -/** - * Persists and restores desktop window geometry (position and size) across application restarts. - * - * Backed by the `CorePreferencesDataStore` [DataStore] instance. Window bounds are written atomically via - * [setWindowBounds] and exposed as [StateFlow] properties for composable consumption. - */ +const val KEY_WINDOW_WIDTH = "window_width" +const val KEY_WINDOW_HEIGHT = "window_height" +const val KEY_WINDOW_X = "window_x" +const val KEY_WINDOW_Y = "window_y" + @Single -class DesktopPreferencesDataSource( - @Named("CorePreferencesDataStore") private val dataStore: DataStore, - dispatchers: CoroutineDispatchers, -) { +class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore) { - private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val windowWidth: StateFlow = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f) val windowHeight: StateFlow = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f) @@ -68,9 +64,9 @@ class DesktopPreferencesDataSource( ): StateFlow = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default) companion object { - val WINDOW_WIDTH = floatPreferencesKey("window_width") - val WINDOW_HEIGHT = floatPreferencesKey("window_height") - val WINDOW_X = floatPreferencesKey("window_x") - val WINDOW_Y = floatPreferencesKey("window_y") + val WINDOW_WIDTH = floatPreferencesKey(KEY_WINDOW_WIDTH) + val WINDOW_HEIGHT = floatPreferencesKey(KEY_WINDOW_HEIGHT) + val WINDOW_X = floatPreferencesKey(KEY_WINDOW_X) + val WINDOW_Y = floatPreferencesKey(KEY_WINDOW_Y) } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt index d27f6d5d9..0bb5311aa 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt @@ -19,10 +19,6 @@ package org.meshtastic.desktop.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module -/** - * Koin module that component-scans the `org.meshtastic.desktop` package for annotated bindings (`@Single`, `@Factory`, - * `@KoinViewModel`). - */ @Module @ComponentScan("org.meshtastic.desktop") class DesktopDiModule diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 8ac634112..b93c16a75 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -14,22 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:Suppress( - "ktlint:standard:no-unused-imports", -) // Koin K2 compiler plugin generates aliased module extensions referenced in desktopModule() - package org.meshtastic.desktop.di // Generated Koin module extensions from core KMP modules import io.ktor.client.HttpClient import io.ktor.client.engine.java.Java -import io.ktor.client.plugins.DefaultRequest -import io.ktor.client.plugins.HttpRequestRetry -import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.request.url import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import org.koin.dsl.module @@ -40,28 +30,17 @@ import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.RadioController -import org.meshtastic.core.network.HttpClientDefaults -import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.repository.MQTTRepository -import org.meshtastic.core.network.service.ApiService -import org.meshtastic.core.network.service.ApiServiceImpl import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.MessageQueue -import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioTransportFactory import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.service.DirectRadioControllerImpl -import org.meshtastic.core.service.ServiceRepositoryImpl -import org.meshtastic.desktop.DesktopBuildConfig -import org.meshtastic.desktop.DesktopNotificationManager -import org.meshtastic.desktop.notification.DesktopMeshServiceNotifications -import org.meshtastic.desktop.radio.DesktopMessageQueue import org.meshtastic.desktop.radio.DesktopRadioTransportFactory import org.meshtastic.desktop.stub.NoopAppWidgetUpdater import org.meshtastic.desktop.stub.NoopCompassHeadingProvider @@ -73,9 +52,6 @@ import org.meshtastic.desktop.stub.NoopMeshWorkerManager import org.meshtastic.desktop.stub.NoopPhoneLocationProvider import org.meshtastic.desktop.stub.NoopPlatformAnalytics import org.meshtastic.desktop.stub.NoopServiceBroadcasts -import org.meshtastic.feature.node.compass.CompassHeadingProvider -import org.meshtastic.feature.node.compass.MagneticFieldProvider -import org.meshtastic.feature.node.compass.PhoneLocationProvider import org.meshtastic.core.ble.di.module as coreBleModule import org.meshtastic.core.common.di.module as coreCommonModule import org.meshtastic.core.data.di.module as coreDataModule @@ -145,7 +121,7 @@ fun desktopModule() = module { */ @Suppress("LongMethod") private fun desktopPlatformStubsModule() = module { - single { ServiceRepositoryImpl() } + single { org.meshtastic.core.service.ServiceRepositoryImpl() } single { DesktopRadioTransportFactory( dispatchers = get(), @@ -155,7 +131,7 @@ private fun desktopPlatformStubsModule() = module { ) } single { - DirectRadioControllerImpl( + org.meshtastic.core.service.DirectRadioControllerImpl( serviceRepository = get(), nodeRepository = get(), commandSender = get(), @@ -165,46 +141,34 @@ private fun desktopPlatformStubsModule() = module { locationManager = get(), ) } - single { DesktopNotificationManager(prefs = get()) } - single { get() } - single { DesktopMeshServiceNotifications(notificationManager = get()) } + single { org.meshtastic.desktop.DesktopNotificationManager(prefs = get()) } + single { + get() + } + single { + org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get()) + } single { NoopPlatformAnalytics() } single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } single { NoopMeshWorkerManager() } - single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) } + single { + org.meshtastic.desktop.radio.DesktopMessageQueue(packetRepository = get(), radioController = get()) + } single { NoopMeshLocationManager() } single { NoopLocationRepository() } single { NoopMQTTRepository() } - single { NoopCompassHeadingProvider() } - single { NoopPhoneLocationProvider() } - single { NoopMagneticFieldProvider() } + single { NoopCompassHeadingProvider() } + single { NoopPhoneLocationProvider() } + single { NoopMagneticFieldProvider() } // Desktop uses the real ApiService implementation (no flavor stub needed) - single { ApiServiceImpl(client = get()) } + single { + org.meshtastic.core.network.service.ApiServiceImpl(client = get()) + } // Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android) - single { - HttpClient(Java) { - install(ContentNegotiation) { json(get()) } - install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) } - install(HttpTimeout) { - requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES) - exponentialDelay() - } - if (DesktopBuildConfig.IS_DEBUG) { - install(Logging) { - logger = KermitHttpLogger - level = LogLevel.BODY - } - } - } - } + single { HttpClient(Java) { install(ContentNegotiation) { json(get()) } } } // Desktop stubs for data sources that load from Android assets on mobile single { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt index 743c2065d..e2fe40da4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -27,6 +27,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okio.FileSystem import okio.Path.Companion.toPath @@ -34,13 +35,10 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.database.desktopDataDir -import org.meshtastic.core.datastore.di.DATASTORE_SCOPE import org.meshtastic.core.datastore.serializer.ChannelSetSerializer import org.meshtastic.core.datastore.serializer.LocalConfigSerializer import org.meshtastic.core.datastore.serializer.LocalStatsSerializer import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig @@ -50,10 +48,10 @@ import org.meshtastic.proto.LocalStats private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore { val dir = desktopDataDir() + "/datastore" FileSystem.SYSTEM.createDirectories(dir.toPath()) - return PreferenceDataStoreFactory.createWithPath( + return PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), scope = scope, - produceFile = { "$dir/$name.preferences_pb".toPath() }, + produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath().toFile() }, ) } @@ -81,25 +79,26 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner { * - [Lifecycle] (`ProcessLifecycle`) * - [BuildConfigProvider] */ +@Suppress("InjectDispatcher") fun desktopPlatformModule() = module { // Application-lifetime scope shared by all DataStore instances. Per the DataStore docs: // "The Job within this context dictates the lifecycle of the DataStore's internal operations. // Ensure it is an application-scoped context that is not canceled by UI lifecycle events." // DataStore has no close() API — the in-memory cache is released only when this Job is cancelled // (at process exit). Using SupervisorJob so a single store's failure doesn't cascade. - single(named(DATASTORE_SCOPE)) { CoroutineScope(get().io + SupervisorJob()) } + val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule()) + includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope)) - // -- Build config (values generated at build time by generateDesktopBuildConfig) -- + // -- Build config -- single { object : BuildConfigProvider { - override val isDebug: Boolean = DesktopBuildConfig.IS_DEBUG - override val applicationId: String = DesktopBuildConfig.APPLICATION_ID - override val versionCode: Int = DesktopBuildConfig.VERSION_CODE - override val versionName: String = DesktopBuildConfig.VERSION_NAME - override val absoluteMinFwVersion: String = DesktopBuildConfig.ABS_MIN_FW_VERSION - override val minFwVersion: String = DesktopBuildConfig.MIN_FW_VERSION + override val isDebug: Boolean = true + override val applicationId: String = "org.meshtastic.desktop" + override val versionCode: Int = 1 + override val versionName: String = "2.7.14" + override val absoluteMinFwVersion: String = "2.3.15" + override val minFwVersion: String = "2.5.14" } } @@ -108,50 +107,30 @@ fun desktopPlatformModule() = module { } /** Named [DataStore]<[Preferences]> instances for all preference domains. */ -private fun desktopPreferencesDataStoreModule() = module { - single>(named("AnalyticsDataStore")) { - createPreferencesDataStore("analytics", get(named(DATASTORE_SCOPE))) - } +private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module { + single>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) } single>(named("HomoglyphEncodingDataStore")) { - createPreferencesDataStore("homoglyph_encoding", get(named(DATASTORE_SCOPE))) - } - single>(named("AppDataStore")) { - createPreferencesDataStore("app", get(named(DATASTORE_SCOPE))) - } - single>(named("CustomEmojiDataStore")) { - createPreferencesDataStore("custom_emoji", get(named(DATASTORE_SCOPE))) - } - single>(named("MapDataStore")) { - createPreferencesDataStore("map", get(named(DATASTORE_SCOPE))) - } - single>(named("MapConsentDataStore")) { - createPreferencesDataStore("map_consent", get(named(DATASTORE_SCOPE))) + createPreferencesDataStore("homoglyph_encoding", scope) } + single>(named("AppDataStore")) { createPreferencesDataStore("app", scope) } + single>(named("CustomEmojiDataStore")) { createPreferencesDataStore("custom_emoji", scope) } + single>(named("MapDataStore")) { createPreferencesDataStore("map", scope) } + single>(named("MapConsentDataStore")) { createPreferencesDataStore("map_consent", scope) } single>(named("MapTileProviderDataStore")) { - createPreferencesDataStore("map_tile_provider", get(named(DATASTORE_SCOPE))) - } - single>(named("MeshDataStore")) { - createPreferencesDataStore("mesh", get(named(DATASTORE_SCOPE))) - } - single>(named("RadioDataStore")) { - createPreferencesDataStore("radio", get(named(DATASTORE_SCOPE))) - } - single>(named("UiDataStore")) { - createPreferencesDataStore("ui", get(named(DATASTORE_SCOPE))) - } - single>(named("MeshLogDataStore")) { - createPreferencesDataStore("meshlog", get(named(DATASTORE_SCOPE))) - } - single>(named("FilterDataStore")) { - createPreferencesDataStore("filter", get(named(DATASTORE_SCOPE))) + createPreferencesDataStore("map_tile_provider", scope) } + single>(named("MeshDataStore")) { createPreferencesDataStore("mesh", scope) } + single>(named("RadioDataStore")) { createPreferencesDataStore("radio", scope) } + single>(named("UiDataStore")) { createPreferencesDataStore("ui", scope) } + single>(named("MeshLogDataStore")) { createPreferencesDataStore("meshlog", scope) } + single>(named("FilterDataStore")) { createPreferencesDataStore("filter", scope) } single>(named("CorePreferencesDataStore")) { - createPreferencesDataStore("core_preferences", get(named(DATASTORE_SCOPE))) + createPreferencesDataStore("core_preferences", scope) } } /** Proto [DataStore] instances (OkioStorage-backed). */ -private fun desktopProtoDataStoreModule() = module { +private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module { val protoDir = desktopDataDir() + "/datastore" single>(named("CoreLocalConfigDataStore")) { @@ -163,7 +142,7 @@ private fun desktopProtoDataStoreModule() = module { producePath = { "$protoDir/local_config.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }), - scope = get(named(DATASTORE_SCOPE)), + scope = scope, ) } @@ -176,7 +155,7 @@ private fun desktopProtoDataStoreModule() = module { producePath = { "$protoDir/module_config.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }), - scope = get(named(DATASTORE_SCOPE)), + scope = scope, ) } @@ -189,7 +168,7 @@ private fun desktopProtoDataStoreModule() = module { producePath = { "$protoDir/channel_set.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }), - scope = get(named(DATASTORE_SCOPE)), + scope = scope, ) } @@ -202,7 +181,7 @@ private fun desktopProtoDataStoreModule() = module { producePath = { "$protoDir/local_stats.pb".toPath() }, ), corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }), - scope = get(named(DATASTORE_SCOPE)), + scope = scope, ) } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index 594a62bc4..f30ecb66b 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -19,7 +19,6 @@ package org.meshtastic.desktop.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey -import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.map.navigation.mapGraph @@ -30,22 +29,42 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph /** - * Registers [NavKey] entry providers for every desktop destination. + * Registers entry providers for all top-level desktop destinations. * - * Each call delegates to the shared navigation graph extension exported by the corresponding feature module, keeping - * the desktop shell free of screen-level composable knowledge. + * Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from + * `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via + * [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their + * shared composables are wired. */ -fun EntryProviderScope.desktopNavGraph(backStack: NavBackStack, uiViewModel: UIViewModel) { +fun EntryProviderScope.desktopNavGraph( + backStack: NavBackStack, + uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel, +) { + // Nodes — real composables from feature:node nodesGraph( backStack = backStack, scrollToTopEvents = uiViewModel.scrollToTopEventFlow, onHandleDeepLink = uiViewModel::handleDeepLink, ) + + // Conversations — real composables from feature:messaging contactsGraph(backStack, uiViewModel.scrollToTopEventFlow) + + // Map — placeholder for now, will be replaced with feature:map real implementation mapGraph(backStack) + + // Firmware — in-flow destination (for example from Settings), not a top-level rail tab firmwareGraph(backStack) + + // Settings — real composables from feature:settings settingsGraph(backStack) + + // Channels channelsGraph(backStack) + + // Connections — shared screen connectionsGraph(backStack) + + // WiFi Provisioning — nymea-networkmanager BLE protocol wifiProvisionGraph(backStack) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index 4cda00251..061da246d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -16,13 +16,11 @@ */ package org.meshtastic.desktop.notification -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.desktop_notification_title import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.low_battery_message import org.meshtastic.core.resources.low_battery_title @@ -31,15 +29,8 @@ import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry /** - * Desktop implementation of [MeshServiceNotifications]. - * - * Converts mesh-layer notification events into domain [Notification] objects and dispatches them through - * [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications. - * - * Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops. - * - * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the - * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. + * Desktop notifications implementation. Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to + * avoid double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. */ @Suppress("TooManyFunctions") class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications { @@ -48,11 +39,15 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat } override fun initChannels() { - // No-op: desktop has no Android notification channels. + // no-op for desktop } - override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) { - // No-op: desktop has no foreground service notification. + override fun updateServiceStateNotification( + state: org.meshtastic.core.model.ConnectionState, + telemetry: Telemetry?, + ): Any { + // We don't have a foreground service on desktop + return Unit } override suspend fun updateMessageNotification( @@ -112,10 +107,16 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat ) } + @Suppress("ktlint:standard:max-line-length") override fun showAlertNotification(contactKey: String, name: String, alert: String) { - val notification = - Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey) - notificationManager.dispatch(notification) + notificationManager.dispatch( + Notification( + title = name, + message = alert, + category = Notification.Category.Alert, + contactKey = contactKey, + ), + ) } override fun showNewNodeSeenNotification(node: Node) { @@ -142,7 +143,7 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat override fun showClientNotification(clientNotification: ClientNotification) { notificationManager.dispatch( Notification( - title = getString(Res.string.desktop_notification_title), + title = "Meshtastic", message = clientNotification.message, category = Notification.Category.Alert, id = clientNotification.toString().hashCode(), diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt index 3888b0af3..c272e7bd9 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt @@ -18,9 +18,9 @@ package org.meshtastic.desktop.radio import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.RadioController @@ -36,9 +36,8 @@ import org.meshtastic.core.repository.PacketRepository class DesktopMessageQueue( private val packetRepository: PacketRepository, private val radioController: RadioController, - dispatchers: CoroutineDispatchers, ) : MessageQueue { - private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override suspend fun enqueue(packetId: Int) { scope.launch { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt index ffaa0553b..0518620c0 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt @@ -24,7 +24,7 @@ import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.network.SerialTransport import org.meshtastic.core.network.radio.BaseRadioTransportFactory -import org.meshtastic.core.network.radio.TcpRadioTransport +import org.meshtastic.core.network.radio.TCPInterface import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory @@ -45,22 +45,16 @@ class DesktopRadioTransportFactory( override val supportedDeviceTypes: List = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB) - override fun isMockTransport(): Boolean = false + override fun isMockInterface(): Boolean = false override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when { address.startsWith(InterfaceId.TCP.id) -> { - TcpRadioTransport( - callback = service, - scope = service.serviceScope, - dispatchers = dispatchers, - address = address.removePrefix(InterfaceId.TCP.id.toString()), - ) + TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString())) } address.startsWith(InterfaceId.SERIAL.id) -> { SerialTransport.open( portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), - callback = service, - scope = service.serviceScope, + service = service, dispatchers = dispatchers, ) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt index b0761522d..5e223ed67 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt @@ -24,18 +24,15 @@ import org.meshtastic.feature.node.compass.MagneticFieldProvider import org.meshtastic.feature.node.compass.PhoneLocationProvider import org.meshtastic.feature.node.compass.PhoneLocationState -/** No-op [CompassHeadingProvider] — desktop has no compass sensor. */ class NoopCompassHeadingProvider : CompassHeadingProvider { override fun headingUpdates(): Flow = flowOf(HeadingState(hasSensor = false)) } -/** No-op [PhoneLocationProvider] — desktop has no GPS provider. */ class NoopPhoneLocationProvider : PhoneLocationProvider { override fun locationUpdates(): Flow = flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false)) } -/** No-op [MagneticFieldProvider] — always returns zero declination. */ class NoopMagneticFieldProvider : MagneticFieldProvider { override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index 707dfaf03..563571ef6 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -20,7 +20,6 @@ package org.meshtastic.desktop.stub import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -28,7 +27,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.MessageStatus @@ -39,12 +37,14 @@ import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.Location import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.MqttClientProxyMessage -import org.meshtastic.mqtt.ConnectionState as MqttConnectionState +import org.meshtastic.proto.Telemetry import org.meshtastic.proto.Position as ProtoPosition /** @@ -66,12 +66,12 @@ private fun logWarn(message: String) { // region Transport / Radio Stubs (Android BLE/USB — no commonMain impl) class NoopRadioInterfaceService : RadioInterfaceService { - override val supportedDeviceTypes: List = emptyList() + override val supportedDeviceTypes: List = emptyList() override val connectionState = MutableStateFlow(ConnectionState.Disconnected) override val currentDeviceAddressFlow = MutableStateFlow(null) - override fun isMockTransport(): Boolean = false + override fun isMockInterface(): Boolean = false override val receivedData = MutableSharedFlow() override val meshActivity = MutableSharedFlow() @@ -81,10 +81,6 @@ 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()") } @@ -102,13 +98,65 @@ class NoopRadioInterfaceService : RadioInterfaceService { override fun handleFromRadio(bytes: ByteArray) {} @Suppress("InjectDispatcher") - override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Default) } // endregion // region Notification / Platform Stubs (Android-only) +@Suppress("TooManyFunctions") +class NoopMeshServiceNotifications : MeshServiceNotifications { + override fun clearNotifications() {} + + override fun initChannels() {} + + override fun updateServiceStateNotification( + state: org.meshtastic.core.model.ConnectionState, + telemetry: Telemetry?, + ): Any = Unit + + override suspend fun updateMessageNotification( + contactKey: String, + name: String, + message: String, + isBroadcast: Boolean, + channelName: String?, + isSilent: Boolean, + ) {} + + override suspend fun updateWaypointNotification( + contactKey: String, + name: String, + message: String, + waypointId: Int, + isSilent: Boolean, + ) {} + + override suspend fun updateReactionNotification( + contactKey: String, + name: String, + emoji: String, + isBroadcast: Boolean, + channelName: String?, + isSilent: Boolean, + ) {} + + override fun showAlertNotification(contactKey: String, name: String, alert: String) {} + + override fun showNewNodeSeenNotification(node: Node) {} + + override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {} + + override fun showClientNotification(clientNotification: ClientNotification) {} + + override fun cancelMessageNotification(contactKey: String) {} + + override fun cancelLowBatteryNotification(node: Node) {} + + override fun clearClientNotification(notification: ClientNotification) {} +} + class NoopPlatformAnalytics : PlatformAnalytics { override fun track(event: String, vararg properties: DataPair) {} @@ -163,8 +211,6 @@ class NoopMQTTRepository : MQTTRepository { override val proxyMessageFlow: Flow = emptyFlow() override fun publish(topic: String, data: ByteArray, retained: Boolean) {} - - override val connectionState = MutableStateFlow(MqttConnectionState.Disconnected.Idle) } // endregion diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index a55bf902f..00b2e82c7 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -31,10 +31,7 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.desktop.navigation.desktopNavGraph -/** - * Desktop main screen — assembles the shared [MeshtasticAppShell], [MeshtasticNavigationSuite], and - * [MeshtasticNavDisplay] with the desktop-specific [desktopNavGraph] entry provider. - */ +/** Desktop main screen — uses shared navigation components. */ @Composable fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) { val backStack = multiBackstack.activeBackStack diff --git a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md index d3dd5ad93..17b152f4a 100644 --- a/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md +++ b/docs/BUILD_LOGIC_CONVENTIONS_GUIDE.md @@ -121,25 +121,35 @@ kotlin { ``` **What the plugin provides automatically:** -- `commonMain`: `compose-multiplatform-material3`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-runtime-compose`, `koin-compose-viewmodel`, `kermit` -- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview` +- `commonMain`: `compose-multiplatform-material3`, `compose-multiplatform-materialIconsExtended`, `jetbrains-lifecycle-viewmodel-compose`, `koin-compose-viewmodel`, `kermit` +- `androidMain`: `androidx-compose-bom` (platform), `accompanist-permissions`, `androidx-activity-compose`, `androidx-compose-material3`, `androidx-compose-material-iconsExtended`, `androidx-compose-ui-text`, `androidx-compose-ui-tooling-preview` - `commonTest`: `core:testing` **Why:** Eliminates ~15 duplicate dependency declarations per feature module (modelled after Now in Android's `AndroidFeatureImplConventionPlugin`). ### Example: Adding Android-specific test config -**Pattern:** Test options (`animationsDisabled`, `testInstrumentationRunner`, `unitTests.isReturnDefaultValues`) are centralized in `configureKotlinAndroid()` via `CommonExtension`, so they apply to both app and library modules automatically. To add new test config, update `KotlinAndroid.kt::configureKotlinAndroid()`: +**Pattern:** Add to `AndroidLibraryConventionPlugin.kt`: ```kotlin -internal fun Project.configureKotlinAndroid( - commonExtension: CommonExtension<*, *, *, *, *, *>, -) { - commonExtension.apply { - testOptions { +extensions.configure { + configureKotlinAndroid(this) + testOptions.apply { + animationsDisabled = true + // NEW: Android-specific test config + unitTests.isIncludeAndroidResources = true + } +} +``` + +**Alternative:** If it applies to both app and library, consider extracting a function: + +```kotlin +internal fun Project.configureAndroidTestOptions() { + extensions.configure { + testOptions.apply { animationsDisabled = true - unitTests.isReturnDefaultValues = true - // NEW: Add shared test options here + // Shared test options } } } @@ -167,8 +177,6 @@ internal fun Project.configureKotlinAndroid( | `AndroidApplicationFlavorsConventionPlugin` ≈ `AndroidLibraryFlavorsConventionPlugin` | **Kept Separate** | Different extension types; small duplication; explicit intent | | `configureKmpTestDependencies()` (7 modules) | **Consolidated** | Large duplication; single source of truth; all KMP modules benefit | | `jvmAndroidMain` hierarchy setup (4 modules) | **Consolidated** | Shared KMP hierarchy pattern; avoids manual `dependsOn(...)` edges and hierarchy warnings | -| `PUBLISHED_MODULES` set (4 usages) | **Consolidated** | Was repeated as `listOf(...)` in 4 places; now a single `setOf(...)` constant in `KotlinAndroid.kt` | -| `SHARED_COMPILER_ARGS` list (2 code paths) | **Consolidated** | Eliminates duplicated `-opt-in` flags between KMP target compilations and `KotlinCompile` task configuration | ## Testing Convention Changes diff --git a/docs/agent-playbooks/README.md b/docs/agent-playbooks/README.md new file mode 100644 index 000000000..5d25a5509 --- /dev/null +++ b/docs/agent-playbooks/README.md @@ -0,0 +1,52 @@ +# Agent Playbooks + +These playbooks are execution-focused guidance for common changes in this repository. + +Use `AGENTS.md` as the source of truth for architecture boundaries and required conventions. If guidance conflicts, follow `AGENTS.md` and current code patterns. + +## Version baseline for external docs + +When checking upstream docs/examples, match these repository-pinned versions from `gradle/libs.versions.toml`: + +- Kotlin: `2.3.20` +- Koin: `4.2.0` (`koin-annotations` `4.2.0` — uses same version as `koin-core`; compiler plugin `0.4.1`) +- JetBrains Navigation 3: `1.1.0-beta01` (`org.jetbrains.androidx.navigation3`) +- JetBrains Lifecycle (multiplatform): `2.11.0-alpha02` (`org.jetbrains.androidx.lifecycle`) +- AndroidX Lifecycle (Android-only): `2.10.0` (`androidx.lifecycle`) +- Kotlin Coroutines: `1.10.2` +- Compose Multiplatform: `1.11.0-beta01` +- JetBrains Material 3 Adaptive: `1.3.0-alpha06` (`org.jetbrains.compose.material3.adaptive`) + +Prefer versioned docs pages that match those versions (for example, Koin `4.2` docs rather than older `4.0/4.1` pages). + +## Dependency alias quick-reference + +Version catalog aliases split cleanly by fork provenance. **Use the right prefix for the right source set.** + +| Alias prefix | Coordinates | Use in | +|---|---|---| +| `jetbrains-lifecycle-*` | `org.jetbrains.androidx.lifecycle:*` | `commonMain`, `androidMain` | +| `jetbrains-navigation3-ui` | `org.jetbrains.androidx.navigation3:navigation3-ui` | `commonMain`, `androidMain` | +| `jetbrains-navigationevent-*` | `org.jetbrains.androidx.navigationevent:*` | `commonMain`, `androidMain` | +| `jetbrains-compose-material3-adaptive-*` | `org.jetbrains.compose.material3.adaptive:*` | `commonMain`, `androidMain` | +| `androidx-lifecycle-process` | `androidx.lifecycle:lifecycle-process` | `androidMain` only — `ProcessLifecycleOwner` | +| `androidx-lifecycle-testing` | `androidx.lifecycle:lifecycle-runtime-testing` | `androidUnitTest` only | + +> **Note:** JetBrains does not publish a separate `navigation3-runtime` artifact — `navigation3-ui` is the only artifact. The version catalog only defines `jetbrains-navigation3-ui`. The `lifecycle-runtime-ktx` and `lifecycle-viewmodel-ktx` KTX aliases were removed (extensions merged into base artifacts since Lifecycle 2.8.0). + +Quick references: + +- Koin annotations (4.2 docs): `https://insert-koin.io/docs/reference/koin-annotations/start` +- Koin KMP docs: `https://insert-koin.io/docs/reference/koin-annotations/kmp` +- AndroidX Navigation 3 release notes: `https://developer.android.com/jetpack/androidx/releases/navigation3` +- Kotlin release notes: `https://kotlinlang.org/docs/releases.html` + +## Playbooks + +- `docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md` - DI and Navigation 3 mistakes to avoid. +- `docs/agent-playbooks/kmp-source-set-bridging-playbook.md` - when to use `expect`/`actual` vs interfaces + app wiring. +- `docs/agent-playbooks/task-playbooks.md` - step-by-step recipes for common implementation tasks, plus code anchor quick reference. +- `docs/agent-playbooks/testing-and-ci-playbook.md` - which Gradle tasks to run based on change type, plus CI parity. + + + diff --git a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md new file mode 100644 index 000000000..550fd2079 --- /dev/null +++ b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md @@ -0,0 +1,58 @@ +# DI and Navigation 3 Anti-Patterns Playbook + +This playbook is a fast guardrail for high-risk mistakes in dependency injection and navigation. + +Version note: align guidance with repository-pinned versions in `gradle/libs.versions.toml` (currently Koin `4.2.x` and Navigation 3 JetBrains fork `1.1.x`). + +## DI anti-patterns + +- Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. +- Do use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules. This provides compile-time safety and encapsulates dependency graphs per feature, which is the recommended 2026 KMP practice for Koin 4.x. +- Don't instantiate ViewModels or service dependencies manually in Compose or activities. +- Do resolve app-layer wrappers via Koin (`koinViewModel()` / injected bindings). +- Don't spread DI graph setup across unrelated modules without registration in app startup. +- Do ensure modules are reachable from app bootstrap in `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`. +- Don't assume feature/core `@Module` classes are active automatically. +- Do ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`. +- **Don't use Koin K2 Compiler Plugin's A1 Module Compile Safety checks for inverted dependencies.** +- **Do** leave A1 `compileSafety` disabled in `build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt` (uses typed `KoinGradleExtension`). We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) to handle our decoupled Clean Architecture design where interfaces are declared in one module and implemented in another. +- **Don't** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values. + +### Current code anchors (DI) + +- App-level module scanning: `app/src/main/kotlin/org/meshtastic/app/MainKoinModule.kt` +- App startup + Koin init: `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` +- Shared ViewModel base: `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` +- Shared base UI ViewModel: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt` + +## Navigation 3 anti-patterns + +- Don't reintroduce controller-coupled navigation APIs for shared flow state. +- Do use Navigation 3 types (`NavKey`, `NavBackStack`, `EntryProviderScope`) consistently. +- Don't build route identifiers as ad-hoc strings in feature code when typed route keys already exist. +- Do keep route definitions in `core:navigation` and use typed route objects. +- Don't mutate back navigation with custom stacks disconnected from app backstack. +- Do mutate `NavBackStack` with `add(...)` and `removeLastOrNull()`. +- Don't use Android's `androidx.activity.compose.BackHandler` or custom `PredictiveBackHandler` in multiplatform UI. +- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures. +- Don't parse deep links manually in platform code or push single routes without a backstack. +- Do use `DeepLinkRouter.route()` in `core:navigation` to synthesize the correct typed backstack from RESTful paths. +- **Don't use a single `NavBackStack` list for multiple tabs, nor reuse the same `NavEntryDecorator` instances across different backstacks.** +- **Do** use `MultiBackstack` (from `core:navigation`) to manage independent `NavBackStack` instances per tab. When rendering the active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance. Failure to swap decorators when swapping backstacks causes Navigation 3 to perceive the inactive entries as "popped", permanently destroying their `ViewModelStore` and saved UI state. + +### Current code anchors (Navigation 3) + +- Typed routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` +- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt` +- App root backstack + `MeshtasticNavDisplay`: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt` +- Shared graph entry provider pattern: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` +- Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` +- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` + + +## Quick pre-PR checks for DI/navigation edits + +- Verify affected graph/module is registered and reachable from app startup. +- Verify no new Android framework type leaks into `commonMain`. +- Verify routes/backstack use typed keys and Navigation 3 primitives. +- Run targeted verification from `docs/agent-playbooks/testing-and-ci-playbook.md`. diff --git a/docs/agent-playbooks/kmp-source-set-bridging-playbook.md b/docs/agent-playbooks/kmp-source-set-bridging-playbook.md new file mode 100644 index 000000000..62753020a --- /dev/null +++ b/docs/agent-playbooks/kmp-source-set-bridging-playbook.md @@ -0,0 +1,45 @@ +# KMP Source-Set Bridging Playbook + +Use this playbook when introducing platform-specific behavior into shared modules. + +## 1) Decide if `expect`/`actual` is needed + +Use `expect`/`actual` only when a platform API cannot be abstracted cleanly behind an interface passed from app wiring. + +- Prefer interface + DI when behavior is already app-owned. +- Prefer `expect`/`actual` for small platform primitives and utilities. + +Examples in current code: +- `core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt` +- `core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt` +- `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocationRepository.kt` + +## 2) Keep source-set boundaries strict + +- `commonMain`: business logic, shared models, coroutine/Flow orchestration. +- `androidMain`: Android framework integration (`Context`, system services, Android SDK). +- `app`: app bootstrap, DI root inclusion, Activity/service wiring, flavor-specific providers. + +## 3) Resource and UI bridging rules + +- Shared strings/resources must come from `core:resources`. +- Platform/flavor UI implementations should be injected via `CompositionLocal` from app. + +Examples: +- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` +- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` +- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` +- Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` + +## 4) DI and module activation checks + +- If a new feature/core module adds Koin annotations, verify it is included by app root module includes. +- App root includes are defined in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`. + +## 5) Verification checklist + +- No Android-only imports in `commonMain`. +- `expect`/`actual` declarations compile across relevant source sets. +- Routing/DI still resolves from app startup (`MeshUtilApplication`). +- Run verification tasks from `docs/agent-playbooks/testing-and-ci-playbook.md` appropriate to touched modules. + diff --git a/docs/agent-playbooks/task-playbooks.md b/docs/agent-playbooks/task-playbooks.md new file mode 100644 index 000000000..25a856d9f --- /dev/null +++ b/docs/agent-playbooks/task-playbooks.md @@ -0,0 +1,113 @@ +# Task Playbooks + +Use these as practical recipes. Keep edits minimal and aligned with existing module boundaries. + +For architecture rules and coding standards, see [`AGENTS.md`](../../AGENTS.md). + +## Code Anchor Quick Reference + +Key files for discovering established patterns: + +| Pattern | Reference File | +|---|---| +| App DI wiring | `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` | +| App startup / Koin bootstrap | `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` | +| Shared ViewModel | `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` | +| `CompositionLocal` platform injection | `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` | +| Platform abstraction contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` | +| Node track map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` | +| Traceroute map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` | +| Shared strings resource | `core/resources/src/commonMain/composeResources/values/strings.xml` | +| Okio shared I/O | `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt` | +| `stateInWhileSubscribed` | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt` | + +## Playbook A: Add or update a user-visible string + +1. Add/update key in `core/resources/src/commonMain/composeResources/values/strings.xml`. +2. Import generated resource symbol in UI code (`org.meshtastic.core.resources.`). +3. Use `stringResource(Res.string.)` in Compose. +4. If the string appears in a shared dialog, prefer `core:ui` dialog components. +5. Verify no hardcoded user-facing strings were introduced. + +Reference examples: +- `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt` +- `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt` + +## Playbook B: Add shared ViewModel logic in a feature module + +1. Implement or extend base ViewModel logic in `feature//src/commonMain/...`. +2. Keep shared class free of Android framework dependencies. +3. Keep Android framework dependencies out of shared logic; if the module already uses Koin annotations in `commonMain`, keep patterns consistent and ensure app root inclusion. +4. Update navigation entry points in `feature/*/src/androidMain/kotlin/org/meshtastic/feature/*/navigation/...` to resolve ViewModels with `koinViewModel()`. + +Reference examples: +- Shared base: `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` +- Shared base UI ViewModel: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt` +- Navigation usage: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` +- Desktop navigation usage: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` + +## Playbook C: Add a new dependency or service binding + +1. Check `gradle/libs.versions.toml` for existing library and version alias. +2. Add new dependency to version catalog first (if truly new). +3. Wire implementation in the owning module (`core:*`, `feature:*`, or `app`) following existing architecture. +4. Register bindings/modules in app Koin graph where needed. +5. For Android system integration (WorkManager, service bootstrapping), wire via `MeshUtilApplication` and app-layer modules. + +Reference examples: +- App startup and Koin bootstrap: `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` +- App module scan: `app/src/main/kotlin/org/meshtastic/app/MainKoinModule.kt` + +## Playbook D: Add or modify navigation flow + +1. Define/extend route keys in `core:navigation`. +2. Implement feature entry/content using Navigation 3 types (`NavKey`, `NavBackStack`, `EntryProviderScope`). +3. Add graph entries under the relevant feature module's `navigation` package (e.g., `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation`). +4. If the entry content depends on platform-specific UI (e.g. Activity context or specific desktop wrappers), use `expect`/`actual` declarations for the content composables. +5. Use backstack mutation (`add`, `removeLastOrNull`) instead of introducing controller-coupled APIs. +6. Verify deep-link behavior if route is externally reachable. + +Reference examples: +- Shared graph wiring: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` +- Android specific content: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt` +- Desktop specific content: `feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt` +- Feature intro graph pattern: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt` +- Desktop nav shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` +- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` + + +## Playbook E: Add flavor/platform-specific UI implementation + +1. Keep shared contracts in `core:ui` or feature shared code. +2. Inject flavor/platform implementation via `CompositionLocal` from `app`. +3. Avoid direct dependency from shared modules to Google Maps/osmdroid/other Android SDK-only APIs. +4. Keep adapter types narrow and stable (interfaces, DTO-like params). + +Reference examples: +- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` +- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` +- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` +- Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` +- Consumer side: `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt` + +## Playbook F: Onboard a new platform target + +1. Create a platform application module (e.g., `desktop/`, `ios/`). +2. Copy `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt` as the starting stub set. All repository interfaces have no-op implementations there. +3. Create a `KoinModule` that mirrors `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` — use stubs for unimplemented interfaces, real implementations where available. +4. Add `kotlinx-coroutines-swing` (JVM/Desktop) or the equivalent platform coroutines dispatcher module. Without it, `Dispatchers.Main` is unavailable and any code using `lifecycle.coroutineScope` will crash at runtime. +5. Progressively replace stubs with real implementations (e.g., serial transport for desktop, CoreBluetooth for iOS). +6. Add `()` target to feature modules as needed (all `core:*` modules already declare `jvm()`). +7. Update CI JVM smoke compile step in `.github/workflows/reusable-check.yml` to include new modules. +8. If `commonMain` code fails to compile for the new target, it's a KMP migration debt — fix the shared code, not the target. + +Reference examples: +- Desktop stubs: `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt` +- Desktop DI: `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` +- Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` +- Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` +- Desktop shared feature wiring: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` +- Desktop-specific screen: `feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt` +- Roadmap: `docs/roadmap.md` + + diff --git a/docs/agent-playbooks/testing-and-ci-playbook.md b/docs/agent-playbooks/testing-and-ci-playbook.md new file mode 100644 index 000000000..a7f0796df --- /dev/null +++ b/docs/agent-playbooks/testing-and-ci-playbook.md @@ -0,0 +1,88 @@ +# Testing and CI Playbook + +Use this matrix to choose the right verification depth for a change. + +## 1) Baseline local verification order + +Run in this order for routine changes: + +```bash +./gradlew clean +./gradlew spotlessCheck +./gradlew spotlessApply +./gradlew detekt +./gradlew assembleDebug +./gradlew test +``` + +Notes: +- This order aligns with repository guidance in `AGENTS.md` and `.github/copilot-instructions.md`. +- CI runs host verification and Android build/device verification in separate jobs inside `.github/workflows/reusable-check.yml`. + +## 2) Change-type matrix + +- `docs-only` changes: + - Usually no Gradle run required. + - If you touched code examples or command docs, at least run `spotlessCheck` if practical. + - If you changed architecture, CI, validation commands, or agent workflow guidance, update the mirrored docs in `AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, and `docs/kmp-status.md` in the same slice. +- `UI text/resource` changes: + - `spotlessCheck`, `detekt`, `assembleDebug`. +- `feature/commonMain logic` changes: + - `spotlessCheck`, `detekt`, `test`, `assembleDebug`. +- `navigation/DI wiring` changes (app graph, Koin module/wrapper changes): + - `spotlessCheck`, `detekt`, `assembleDebug`, `test`, plus `testFdroidDebugUnitTest` and `testGoogleDebugUnitTest` when available locally. + - If touching any KMP module, also run `kmpSmokeCompile`. +- `worker/service/background` changes: + - `spotlessCheck`, `detekt`, `assembleDebug`, `test`, and targeted tests around WorkManager/service behavior. +- `BLE/networking/core repository` changes: + - `spotlessCheck`, `detekt`, `assembleDebug`, `test`. + +## 3) Flavor and instrumentation checks + +Run these when relevant to map/provider/flavor-specific behavior: + +```bash +./gradlew lintFdroidDebug lintGoogleDebug +./gradlew testFdroidDebug +./gradlew testGoogleDebug +./gradlew connectedAndroidTest +``` + +## 4) CI parity checks + +Current reusable check workflow includes: + +- `spotlessCheck detekt` +- Android lint for all directly runnable Android modules: + `app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug` + *(Note: `mesh_service_example:lintDebug` is temporary — the module is deprecated and will be + removed along with its CI tasks in a future release.)* +- Host tests plus coverage aggregation: + `test koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug core:api:koverXmlReportDebug core:barcode:koverXmlReportFdroidDebug core:barcode:koverXmlReportGoogleDebug mesh_service_example:koverXmlReportDebug desktop:koverXmlReport` + *(Note: `mesh_service_example:koverXmlReportDebug` is temporary — see above.)* +- KMP smoke compile lifecycle task (auto-discovers KMP modules and runs JVM + iOS simulator compile checks): + `kmpSmokeCompile` +- Android build tasks: + `app:assembleFdroidDebug app:assembleGoogleDebug mesh_service_example:assembleDebug` + *(Note: `mesh_service_example:assembleDebug` is temporary — see above.)* +- Instrumented tests (when emulator tests are enabled): + `app:connectedFdroidDebugAndroidTest app:connectedGoogleDebugAndroidTest core:barcode:connectedFdroidDebugAndroidTest core:barcode:connectedGoogleDebugAndroidTest` +- Coverage uploads happen once from the host job; instrumented test results upload once from the first Android matrix API to avoid duplicate reporting. + +Reference: `.github/workflows/reusable-check.yml` + +PR workflow note: + +- `.github/workflows/pull-request.yml` ignores docs-only changes (`**/*.md`, `docs/**`), so doc-only PRs may skip Android CI by design. +- PR change detection includes workflow/build/config paths such as `.github/workflows/**`, `desktop/**`, `mesh_service_example/**` (deprecated, will be removed), `config/**`, `gradle/**`, `settings.gradle.kts`, and `test.gradle.kts`. +- Android CI on PRs runs with `run_instrumented_tests: false`; merge queue keeps the full emulator matrix on API 26 and 35. +- Gradle cache writes are enabled for trusted refs/events (`main`, `merge_group`, and `gh-readonly-queue/*`); other refs run in read-only cache mode. + +## 5) Practical guidance for agents + +- Start with the smallest set that validates your touched area. +- Keep documentation continuously in sync with architecture, CI, and workflow changes; do not defer doc fixes to a later PR. +- If modifying cross-module contracts (routes, repository interfaces, DI graph), run the broader baseline. +- If unable to run full validation locally, report exactly what ran and what remains. + + diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 000000000..e8916d8a3 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,15 @@ +# Decision Records + +Architectural decision records and reviews. Each captures context, decision, and consequences. + +| Decision | File | Status | +|---|---|---| +| Architecture review (March 2026) | [`architecture-review-2026-03.md`](./architecture-review-2026-03.md) | Active | +| Navigation 3 parity strategy (Android + Desktop) | [`navigation3-parity-2026-03.md`](./navigation3-parity-2026-03.md) | Active | +| Navigation 3 API alignment audit | [`navigation3-api-alignment-2026-03.md`](./navigation3-api-alignment-2026-03.md) | Active | +| BLE KMP strategy (Kable) | [`ble-strategy.md`](./ble-strategy.md) | Decided | +| Hilt → Koin migration | [`koin-migration.md`](./koin-migration.md) | Complete | +| Testing consolidation (`core:testing`) | [`testing-consolidation-2026-03.md`](./testing-consolidation-2026-03.md) | Complete | + +For the current KMP migration status, see [`docs/kmp-status.md`](../kmp-status.md). +For the forward-looking roadmap, see [`docs/roadmap.md`](../roadmap.md). diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md new file mode 100644 index 000000000..3d09d68f3 --- /dev/null +++ b/docs/decisions/architecture-review-2026-03.md @@ -0,0 +1,255 @@ +# Architecture Review — March 2026 + +> Status: **Active** +> Last updated: 2026-03-31 + +Re-evaluation of project modularity and architecture against modern KMP and Android best practices. Identifies gaps and actionable improvements across modularity, reusability, clean abstractions, DI, and testing. + +## Executive Summary + +The codebase is **~98% structurally KMP** — 18/20 core modules and 8/8 feature modules declare `jvm()` targets and cross-compile in CI. Shared `commonMain` code accounts for ~52K LOC vs ~18K platform-specific LOC (a 74/26 split). This is strong. + +Of the five structural gaps originally identified, four are resolved and one remains in progress: + +1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 8 files: `MainActivity`, `MeshUtilApplication`, Nav shell, DI config, and shared map UI components)* +2. ~~**Radio transport layer is app-locked**~~ — ✅ Resolved: `RadioTransport` interface in `core:repository/commonMain`; shared `StreamFrameCodec` + `TcpTransport` in `core:network`. +3. ~~**`java.*` APIs leak into `commonMain`**~~ — ✅ Resolved: `Locale`, `ConcurrentHashMap`, `ReentrantLock` purged. +4. ~~**Zero feature-level `commonTest`**~~ — ✅ Resolved: 193 shared tests across all 8 features; `core:testing` module established. +5. ~~**No `feature:connections` module**~~ — ✅ Resolved: KMP module with shared UI and dynamic transport detection. + +## Source Code Distribution + +| Source set | Files | ~LOC | Purpose | +|---|---:|---:|---| +| `core/*/commonMain` | 337 | 32,700 | Shared business/data logic | +| `feature/*/commonMain` | 146 | 19,700 | Shared feature UI + ViewModels | +| `feature/*/androidMain` | 62 | 14,700 | Platform UI (charts, previews, permissions) | +| `app/src/main` | 8 | ~450 | Android app shell + shared map UI components | +| `desktop/src` | 26 | 4,800 | Desktop app shell | +| `core/*/androidMain` | 49 | 3,500 | Platform implementations | +| `core/*/jvmMain` | 11 | ~500 | JVM actuals | +| `core/*/jvmAndroidMain` | 4 | ~200 | Shared JVM+Android code | + +**Key ratio:** 74% of production code is in `commonMain` (shared). Goal: 85%+. + +--- + +## A. Critical Modularity Gaps + +### A1. `app` module is a God module + +The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host, and shared flavor-agnostic UI. Originally it held **90 files / ~11K LOC**, now reduced to an **8-file shell** (6 original + 2 shared map UI components: `MapButton`, `MapControlsOverlay`): + +| Area | Files | LOC | Where it should live | +|---|---:|---:|---| +| `repository/radio/` | 22 | ~2,000 | `core:service` / `core:network` | +| `service/` | 12 | ~1,500 | Extracted to `core:service/androidMain` ✓ | +| `navigation/` | ~1 | ~200 | Root Nav 3 host wiring stays in `app`. Feature graphs moved to `feature:*`. | +| `settings/` ViewModels | 3 | ~350 | Thin Android wrappers (genuine platform deps) | +| `widget/` | 4 | ~300 | Extracted to `feature:widget` ✓ | +| `worker/` | 4 | ~350 | Extracted to `core:service/androidMain` and `feature:messaging/androidMain` ✓ | +| DI + Application + MainActivity | 5 | ~500 | Stay in `app` ✓ | +| UI screens + ViewModels | 5 | ~1,200 | Stay in `app` (Android-specific deps) | + +**Progress:** Extracted `ChannelViewModel` → `feature:settings/commonMain`, `NodeMapViewModel` → `feature:map/commonMain`, `NodeContextMenu` → `feature:node/commonMain`, `EmptyDetailPlaceholder` → `core:ui/commonMain`. Remaining extractions require radio/service layer refactoring (bigger scope). + +### A2. Radio interface layer is app-locked and non-KMP + +The core transport abstraction was previously locked in `app/repository/radio/` via `IRadioInterface`. This has been successfully refactored: + +1. Defined `RadioTransport` interface in `core:repository/commonMain` (replacing `IRadioInterface`) +2. Moved `StreamFrameCodec`-based framing to `core:network/commonMain` +3. Moved TCP transport to `core:network/jvmAndroidMain` +4. The remaining `app/repository/radio/` implementations (BLE, Serial, Mock) now implement `RadioTransport`. + +**Recommended next steps:** +1. Move BLE transport to `core:ble/androidMain` +2. Move Serial/USB transport to `core:service/androidMain` + +### A3. No `feature:connections` module *(resolved 2026-03-12)* + +Device discovery UI was duplicated: +- Android: `app/ui/connections/` (13 files: `ConnectionsScreen`, `ScannerViewModel`, 10 components) +- Desktop: `desktop/ui/connections/DesktopConnectionsScreen.kt` (separate implementation) + +**Outcome:** Created `feature:connections` KMP module with: +- `commonMain`: `ScannerViewModel`, `ConnectionsScreen`, 11 shared UI components, `DeviceListEntry` sealed class, `GetDiscoveredDevicesUseCase` interface, `CommonGetDiscoveredDevicesUseCase` (TCP/recent devices) +- `androidMain`: `AndroidScannerViewModel` (BLE bonding, USB permissions), `AndroidGetDiscoveredDevicesUseCase` (BLE/NSD/USB discovery), `NetworkRepository`, `UsbRepository`, `SerialConnection` +- Desktop uses the shared `ConnectionsScreen` + `CommonGetDiscoveredDevicesUseCase` directly +- Dynamic transport detection via `RadioInterfaceService.supportedDeviceTypes` +- Module registered in both `AppKoinModule` and `DesktopKoinModule` + +### A4. `core:api` AIDL coupling + +`core:api` is Android-only (AIDL IPC). `ServiceClient` in `core:service/androidMain` wraps it. Desktop doesn't use it — it has `DirectRadioControllerImpl` in `core:service/commonMain`. + +**Recommendation:** The `DirectRadioControllerImpl` pattern is correct. Ensure `RadioController` (already in `core:model/commonMain`) is the canonical interface; deprecate the AIDL-based path for in-process usage. + +--- + +## B. KMP Platform Purity + +### B1. `java.util.Locale` leaks in `commonMain` *(resolved 2026-03-11)* + +| File | Usage | +|---|---| +| `core:data/.../TracerouteHandlerImpl.kt` | Replaced with `NumberFormatter.format(seconds, 1)` | +| `core:data/.../NeighborInfoHandlerImpl.kt` | Replaced with `NumberFormatter.format(seconds, 1)` | +| `core:prefs/.../MeshPrefsImpl.kt` | Replaced with locale-free `uppercase()` | + +**Outcome:** The three `Locale` usages identified in March were removed from `commonMain`. Follow-up cleanup in the same sprint also moved `ReentrantLock`-based `SyncContinuation` to `jvmAndroidMain`, replaced prefs `ConcurrentHashMap` caches with atomic persistent maps, and pushed enum reflection behind `expect`/`actual` so no known `java.*` runtime calls remain in `commonMain`. + +### B2. `ConcurrentHashMap` leaks in `commonMain` *(resolved 2026-03-11)* + +Formerly found in 3 prefs files: +- `core:prefs/.../MeshPrefsImpl.kt` +- `core:prefs/.../UiPrefsImpl.kt` +- `core:prefs/.../MapConsentPrefsImpl.kt` + +**Outcome:** These caches now use `AtomicRef>` helpers in `commonMain`, eliminating the last `ConcurrentHashMap` usage from shared prefs code. + +### B3. MQTT (Resolved) + +`MQTTRepositoryImpl` has been migrated to `commonMain` using KMQTT, replacing Eclipse Paho. + +**Fix:** Completed. +- `kmqtt` library integrated for full KMP support. + +### B4. Vico charts *(resolved)* + +Vico chart screens (DeviceMetrics, EnvironmentMetrics, SignalMetrics, PowerMetrics, PaxMetrics) have been migrated to `feature:node/commonMain` using Vico's KMP artifacts (`vico-compose`, `vico-compose-m3`). Desktop wires them via shared composables. No Android-only chart code remains. + +### B5. Cross-platform code deduplication *(resolved 2026-03-21)* + +Comprehensive audit of `androidMain` vs `jvmMain` duplication across all feature modules. Extracted shared components: + +| Component | Module | Eliminated from | +|---|---|---| +| `AlertHost` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` | +| `SharedDialogs` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` | +| `PlaceholderScreen` composable | `core:ui/commonMain` | 4 copies: `desktop/navigation`, `feature:map/jvmMain`, `feature:node/jvmMain` (×2) | +| `ThemePickerDialog` + `ThemeOption` | `feature:settings/commonMain` | Android `SettingsScreen.kt`, Desktop `DesktopSettingsScreen.kt` | +| `formatLogsTo()` + `redactedKeys` | `feature:settings/commonMain` (`LogFormatter.kt`) | Android + Desktop `LogExporter.kt` actuals | +| `handleNodeAction()` | `feature:node/commonMain` | Android `NodeDetailScreen.kt`, Desktop `NodeDetailScreens.kt` | +| `findNodeByNameSuffix()` | `feature:connections/commonMain` | Android USB matcher, TCP recent device matcher | + +Also fixed `Dispatchers.IO` usage in `StoreForwardPacketHandlerImpl` (would break iOS), removed dead `UIViewModel.currentAlert` property, and added `firebase-debug.log` to `.gitignore`. + +--- + +## C. DI Improvements + +### C1. ~~Desktop manual ViewModel wiring~~ *(resolved 2026-03-13)* + +`DesktopKoinModule.kt` originally had ~120 lines of hand-written `viewModel { ... }` blocks. These have been successfully replaced by including Koin modules from `commonMain` generated via the Koin K2 Compiler Plugin for automatic wiring. + +### C2. ~~Desktop stubs lack compile-time validation~~ *(resolved 2026-03-13)* + +`desktopPlatformStubsModule()` previously had stubs that were only validated at runtime. + +**Outcome:** Added `DesktopKoinTest.kt` using Koin's `verify()` API. This test validates the entire Desktop DI graph (including platform stubs and DataStores) during the build. Discovered and fixed missing stubs for `CompassHeadingProvider`, `PhoneLocationProvider`, and `MagneticFieldProvider`. + +### C3. DI module naming convention + +Android uses `@Module`-annotated classes (`CoreDataModule`, `CoreBleAndroidModule`). Desktop imports them as `CoreDataModule().coreDataModule()`. This works but the double-invocation pattern is non-obvious. + +**Recommendation:** Document the pattern in AGENTS.md. Consider if Koin Annotations 2.x supports a simpler import syntax. + +--- + +## D. Test Architecture + +### D1. Zero `commonTest` in feature modules *(resolved 2026-03-12)* + +| Module | `commonTest` | `test`/`androidUnitTest` | `androidTest` | +|---|---:|---:|---:| +| `feature:settings` | 22 | 20 | 15 | +| `feature:node` | 24 | 9 | 0 | +| `feature:messaging` | 18 | 5 | 3 | +| `feature:connections` | 27 | 0 | 0 | +| `feature:firmware` | 15 | 25 | 0 | +| `feature:wifi-provision` | 62 | 0 | 0 | + +**Outcome:** All 8 feature modules now have `commonTest` coverage (193 shared tests). Combined with 70 platform unit tests and 18 instrumented tests, feature modules have 281 tests total. + +### D2. No shared test fixtures *(resolved 2026-03-12)* + +`core:testing` module established with shared fakes (`FakeNodeRepository`, `FakeServiceRepository`, `FakeRadioController`, `FakePacketRepository`) and `TestDataFactory` builders. Used by all feature `commonTest` suites. + +### D3. Core module test gaps + +36 `commonTest` files exist but are concentrated in `core:domain` (22 files) and `core:data` (10 files). Limited or zero tests in: +- `core:service` (has `ServiceRepositoryImpl`, `DirectRadioControllerImpl`, `MeshServiceOrchestrator`) +- `core:network` (has `StreamFrameCodecTest` — 10 tests; `TcpTransport` untested) +- `core:prefs` (preference flows, default values) +- `core:ble` (connection state machine) +- `core:ui` (utility functions) + +### D4. Desktop has 2 tests + +`desktop/src/test/` contains `DesktopKoinTest.kt` and `DesktopTopLevelDestinationParityTest.kt`. Still needs: +- Navigation graph coverage + +--- + +## E. Module Extraction Priority + +Ordered by impact × effort: + +| Priority | Extraction | Impact | Effort | Enables | +|---:|---|---|---|---| +| 1 | ~~`java.*` purge from `commonMain` (B1, B2)~~ | High | Low | ~~iOS target declaration~~ ✅ Done | +| 2 | Radio transport interfaces to `core:repository` (A2) | High | Medium | Transport unification | +| 3 | `core:testing` shared fixtures (D2) | Medium | Low | Feature commonTest | +| 4 | Feature `commonTest` (D1) | Medium | Medium | KMP test coverage | +| 5 | `feature:connections` (A3) | High | Medium | ~~Desktop connections~~ ✅ Done | +| 6 | Service/worker extraction from `app` (A1) | Medium | Medium | Thin app module | +| 7 | ~~Desktop Koin auto-wiring (C1, C2)~~ | Medium | Low | ✅ Resolved 2026-03-13 | +| 8 | MQTT KMP (B3) | Medium | High | Desktop/iOS MQTT | +| 9 | KMP charts (B4) | Medium | High | Desktop metrics | +| 10 | ~~iOS target declaration~~ | High | Low | ~~CI purity gate~~ ✅ Done | + +--- + +## Scorecard Update + +| Area | Previous | Current | Notes | +|---|---:|---:|---| +| Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared | +| Shared feature/UI logic | 9.5/10 | **9/10** | All 8 KMP features; connections unified; cross-platform deduplication complete | +| Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files | +| Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | +| CI confidence | 8.5/10 | **9/10** | 26 modules validated; feature:connections + feature:wifi-provision + desktop in CI; native release installers | +| DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | +| Test maturity | — | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | + +--- + +## F. JVM/Desktop Database Lifecycle + +Room KMP's `setAutoCloseTimeout` API is Android-only. On JVM/Desktop, once a Room database is built, its SQLite connections (5 per WAL-mode DB: 4 readers + 1 writer) remain open indefinitely until explicitly closed via `RoomDatabase.close()`. + +### Problem + +When a user switches between multiple mesh devices, the previous device's database remained open in the in-memory cache. Each idle database consumed ~32 MB (connection pool + prepared statement caches), leading to unbounded memory growth proportional to the number of devices ever connected in a session. + +### Solution + +`DatabaseManager.switchActiveDatabase()` now explicitly closes the previously active database via `closeCachedDatabase()` before activating the new one. The closed database is removed from the in-memory cache but its file is preserved, allowing transparent re-opening on next access. + +Additional fixes applied: +1. **Init-order bug**: `dbCache` was declared after `currentDb`, causing NPE during `stateIn`'s `initialValue` evaluation. Reordered to ensure `dbCache` is initialized first. +2. **Corruption handlers**: `ReplaceFileCorruptionHandler` added to `createDatabaseDataStore()` on both JVM and Android, preventing DataStore corruption from crashing the app. +3. **`desktopDataDir()` deduplication**: Made public in `core:database/jvmMain` and removed the duplicate from `DesktopPlatformModule`, establishing a single source of truth for the desktop data directory. +4. **DataStore scope consolidation**: Replaced two separate `CoroutineScope` instances with a single shared `dataStoreScope` in `DesktopPlatformModule`. +5. **Coil cache path**: Desktop `Main.kt` updated to use `desktopDataDir()` instead of hardcoded `user.home`. + +--- + +## References + +- Current migration status: [`kmp-status.md`](./kmp-status.md) +- Roadmap: [`roadmap.md`](./roadmap.md) +- Agent guide: [`../AGENTS.md`](../AGENTS.md) +- Decision records: [`decisions/`](./decisions/) + diff --git a/docs/decisions/navigation3-api-alignment-2026-03.md b/docs/decisions/navigation3-api-alignment-2026-03.md new file mode 100644 index 000000000..6a0925152 --- /dev/null +++ b/docs/decisions/navigation3-api-alignment-2026-03.md @@ -0,0 +1,124 @@ + + +# Navigation 3 & Material 3 Adaptive — API Alignment Audit + +**Date:** 2026-03-26 +**Status:** Active +**Scope:** Adoption of Navigation 3 `1.1.0-beta01` Scene APIs, transition metadata, ViewModel scoping, and Material 3 Adaptive integration. +**Supersedes:** [`navigation3-parity-2026-03.md`](navigation3-parity-2026-03.md) Alpha04 Changelog section (versions updated). + +## Current Dependency Baseline + +| Library | Version | Group | +|---|---|---| +| Navigation 3 UI | `1.1.0-beta01` | `org.jetbrains.androidx.navigation3:navigation3-ui` | +| Navigation Event | `1.1.0-alpha01` | `org.jetbrains.androidx.navigationevent:navigationevent-compose` | +| Lifecycle ViewModel Navigation3 | `2.11.0-alpha02` | `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3` | +| Material 3 Adaptive | `1.3.0-alpha06` | `org.jetbrains.compose.material3.adaptive:adaptive*` | +| Material 3 Adaptive Navigation Suite | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3-adaptive-navigation-suite` | +| Compose Multiplatform | `1.11.0-beta01` | `org.jetbrains.compose` | +| Compose Multiplatform Material 3 | `1.11.0-alpha05` | `org.jetbrains.compose.material3:material3` | + +## API Audit: What's Available vs. What We Use + +### 1. NavDisplay — Scene Architecture (available since `1.1.0-alpha04`, stable in `beta01`) + +**Available APIs we're NOT using:** + +| API | Purpose | Status in project | +|---|---|---| +| `sceneStrategies: List>` | Allows NavDisplay to render multi-pane Scenes | ✅ Used — `DialogSceneStrategy`, `ListDetailSceneStrategy`, `SupportingPaneSceneStrategy`, `SinglePaneSceneStrategy` | +| `SceneStrategy` interface | Custom scene calculation from backstack entries | ✅ Used via built-in strategies | +| `DialogSceneStrategy` | Renders `entry(metadata = dialog())` entries as overlay Dialogs | ✅ Adopted | +| `SceneDecoratorStrategy` | Wraps/decorates scenes with additional UI | ❌ Not used | +| `NavEntry.metadata` | Attaches typed metadata to entries (transitions, dialog hints, Scene classification) | ✅ Used — `ListDetailSceneStrategy.listPane()`, `.detailPane()`, `.extraPane()` | +| `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-entry custom transitions via metadata | ❌ Not used | +| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ✅ Used — 350 ms crossfade | +| `sharedTransitionScope: SharedTransitionScope?` | Shared element transitions between scenes | ❌ Not used | +| `entryDecorators: List>` | Wraps entry content with additional behavior | ✅ Used — `SaveableStateHolderNavEntryDecorator` + `ViewModelStoreNavEntryDecorator` | + +**APIs we ARE using correctly:** + +| API | Usage | +|---|---| +| `NavDisplay(backStack, entryProvider, modifier)` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` | +| `rememberNavBackStack(SavedStateConfiguration, startKey)` | Backstack persistence | +| `entryProvider { entry { ... } }` | All feature graph registrations | +| `NavigationBackHandler` from `navigationevent-compose` | Used with `ListDetailSceneStrategy` | + +### 2. ViewModel Scoping (`lifecycle-viewmodel-navigation3` `2.11.0-alpha02`) + +**Key finding:** The `ViewModelStoreNavEntryDecorator` is available and provides automatic per-entry ViewModel scoping tied to backstack lifetime. The project passes it as an `entryDecorator` to `NavDisplay` via `MeshtasticNavDisplay` in `core:ui/commonMain`. + +ViewModels obtained via `koinViewModel()` inside `entry` blocks are scoped to the entry's backstack lifetime and automatically cleared when the entry is popped. + +### 3. Material 3 Adaptive — Nav3 Scene Integration + +**Key finding:** The JetBrains `adaptive-navigation3` artifact at `1.3.0-alpha06` includes `ListDetailSceneStrategy` and `SupportingPaneSceneStrategy`. The project uses both via `rememberListDetailSceneStrategy` and `rememberSupportingPaneSceneStrategy` in `MeshtasticNavDisplay`, with draggable pane dividers via `VerticalDragHandle` + `paneExpansionDraggable`. + +This means the project **successfully** uses the M3 Adaptive Scene bridge through `NavDisplay(sceneStrategies = ...)`. Feature entries annotate themselves with `ListDetailSceneStrategy.listPane()`, `.detailPane()`, or `.extraPane()` metadata. + +**When to revisit:** Monitor the JetBrains adaptive fork for `MaterialListDetailSceneStrategy` inclusion. It will likely arrive when the JetBrains fork catches up to the AndroidX `1.3.0-alpha09+` feature set. + +### 4. NavigationSuiteScaffold (`1.11.0-alpha05`) + +**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavigationSuite` now uses `NavigationSuiteScaffold` with `calculateFromAdaptiveInfo()` and custom `NavigationSuiteType` coercion. No further alignment needed. + +## Prioritized Opportunities + +### P0: Add `ViewModelStoreNavEntryDecorator` to NavDisplay (high-value, low-risk) + +**Status:** ✅ Adopted (2026-03-26). Each backstack entry now gets its own `ViewModelStoreOwner` via `rememberViewModelStoreNavEntryDecorator()`. ViewModels obtained via `koinViewModel()` are automatically cleared when their entry is popped. Encapsulated in `MeshtasticNavDisplay` in `core:ui/commonMain`. + +**Impact:** Fixes subtle ViewModel leaks where popped entries retain their ViewModel in the Activity/Window store. Eliminates the need for manual `key = "metrics-$destNum"` ViewModel keying patterns over time. + +### P1: Add default NavDisplay transitions (medium-value, low-risk) + +**Status:** ✅ Adopted (2026-03-26). A shared 350 ms crossfade (`fadeIn` + `fadeOut`) is applied for both forward and pop navigation via `MeshtasticNavDisplay`. This replaces the library's platform defaults (Android: 700 ms fade; Desktop: no animation) with a faster, consistent transition. + +**Impact:** Immediate UX improvement on both Android and Desktop. Desktop now has visible navigation transitions. + +### P2: Adopt `DialogSceneStrategy` for navigation-driven dialogs (medium-value, medium-risk) + +**Status:** ✅ Adopted (2026-03-26). `MeshtasticNavDisplay` includes `DialogSceneStrategy` in `sceneStrategies` before `SinglePaneSceneStrategy`. Feature modules can now use `entry(metadata = DialogSceneStrategy.dialog()) { ... }` to render entries as overlay Dialogs with proper backstack lifecycle and predictive-back support. + +**Impact:** Cleaner dialog lifecycle management available for future dialog routes. Existing dialogs via `AlertHost` are unaffected. + +### Consolidation: `MeshtasticNavDisplay` shared wrapper + +**Status:** ✅ Adopted (2026-03-26). A new `MeshtasticNavDisplay` composable in `core:ui/commonMain` encapsulates the standard `NavDisplay` configuration: +- Entry decorators: `rememberSaveableStateHolderNavEntryDecorator` + `rememberViewModelStoreNavEntryDecorator` +- Scene strategies: `DialogSceneStrategy` + `SinglePaneSceneStrategy` +- Transition specs: 350 ms crossfade (forward + pop) + +Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` now call `MeshtasticNavDisplay` instead of configuring `NavDisplay` directly. The `lifecycle-viewmodel-navigation3` dependency was moved from host modules to `core:ui`. + +### P3: Per-entry transition metadata (low-value until Scene adoption) + +Individual entries can declare custom transitions via `entry(metadata = NavDisplay.transitionSpec { ... })`. This is most useful when different route types should animate differently (e.g., detail screens slide in, settings screens fade). + +**Impact:** Polish improvement. Low priority until default transitions (P1) are established. Now unblocked by P1 adoption. + +### Deferred: Custom Scene strategies + +The `ListDetailSceneStrategy` and `SupportingPaneSceneStrategy` are adopted and working. Consider writing additional custom `SceneStrategy` implementations for specialized layouts (e.g., three-pane "Power User" scenes) as the Navigation 3 Scene API matures. + +## Decision + +~~Adopt **P0** (ViewModel scoping) and **P1** (default transitions) now. Defer P2/P3 and Scene-based multi-pane until the JetBrains adaptive fork adds `MaterialListDetailSceneStrategy`.~~ + +**Updated 2026-03-26:** P0, P1, and P2 adopted and consolidated into `MeshtasticNavDisplay` in `core:ui/commonMain`. P3 (per-entry transitions) is available for incremental adoption by feature modules. Scene-based multi-pane remains deferred. + +## References + +- Navigation 3 source: `navigation3-ui` `1.1.0-beta01` (inspected from Gradle cache) +- [`NavDisplay.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/ui/NavDisplay.kt) (upstream) +- [`SceneStrategy.kt`](https://cs.android.com/androidx/platform/frameworks/support/+/main:navigation3/navigation3-ui/src/commonMain/kotlin/androidx/navigation3/scene/SceneStrategy.kt) (upstream) +- Material 3 Adaptive JetBrains fork: `org.jetbrains.compose.material3.adaptive` `1.3.0-alpha06` diff --git a/docs/decisions/navigation3-parity-2026-03.md b/docs/decisions/navigation3-parity-2026-03.md new file mode 100644 index 000000000..1d1a8c7ed --- /dev/null +++ b/docs/decisions/navigation3-parity-2026-03.md @@ -0,0 +1,167 @@ + + +# Navigation 3 Parity Strategy (Android + Desktop) + +**Date:** 2026-03-11 +**Status:** Implemented (2026-03-21) +**Scope:** `app` and `desktop` navigation structure using shared `core:navigation` routes + +## Context + +Desktop and Android both use Navigation 3 typed routes from `core:navigation`. Previously graph wiring had diverged — desktop used a separate `DesktopDestination` enum with 6 entries (including a top-level Firmware tab) while Android used 5 entries. + +This has been resolved. Both shells now use the shared `TopLevelDestination` enum from `core:navigation/commonMain` with 5 entries (Conversations, Nodes, Map, Settings, Connections). Firmware is an in-flow route on both platforms. + +Both modules still define separate graph-builder files (`app/navigation/*.kt`, `desktop/navigation/*.kt`) with different destination coverage and placeholder behavior, but the **top-level shell structure is unified**. + +## Current-State Findings + +1. **Top-level destinations are unified.** + - Both shells iterate `TopLevelDestination.entries` from `core:navigation/commonMain`. + - Shared icon mapping lives in `core:ui` (`TopLevelDestinationExt.icon`). + - Parity tests exist in both `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). +2. **Feature coverage is unified via `commonMain` feature graphs.** + - The `settingsGraph`, `nodesGraph`, `contactsGraph`, `connectionsGraph`, `firmwareGraph`, and `mapGraph` are now fully shared and exported from their respective feature modules' `commonMain` source sets. + - Desktop acts as a thin shell, delegating directly to these shared graphs. +3. **Saved-state route registration is fully shared.** + - `MeshtasticNavSavedStateConfig` in `core:navigation/commonMain` maintains the unified `SavedStateConfiguration` serializer list. + - Both Android and Desktop reference this shared config when instantiating `rememberNavBackStack`. +4. **Predictive back handling is KMP native.** + - Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`. + +## Alpha04 → Beta01 Changelog Impact Check + +Source reviewed: Navigation 3 `1.1.0-beta01` (JetBrains fork), CMP `1.11.0-beta01`, Lifecycle `2.11.0-alpha02`. + +> **Superseded by:** [`navigation3-api-alignment-2026-03.md`](navigation3-api-alignment-2026-03.md) for the full API surface audit and Scene architecture adoption plan. + +1. **NavDisplay API updated to Scene-based architecture.** + - The `sceneStrategy: SceneStrategy` parameter is deprecated in favor of `sceneStrategies: List>`. + - New `sceneDecoratorStrategies: List>` parameter available. + - New `sharedTransitionScope: SharedTransitionScope?` parameter for shared element transitions. + - Existing shell patterns in `app` and `desktop` remain valid using the default `SinglePaneSceneStrategy`. +2. **Entry-scoped ViewModel lifecycle adopted.** + - Both `app` and `desktop` now use `MeshtasticNavDisplay` (`core:ui/commonMain`), which applies `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` per active backstack. + - ViewModels obtained via `koinViewModel()` inside `entry` blocks are now scoped to the entry's backstack lifetime. +3. **No direct Navigation 3 API breakage.** + - Release is beta (API stabilized). No migration from alpha04 was required for existing usage patterns. +4. **Primary risk is dependency wiring drift, not runtime behavior.** + - JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog. + - Note: The `remember*` composable factory functions from `navigation3-runtime` are not visible in non-KMP Android modules due to Kotlin metadata resolution. Use direct class constructors instead (as done in `app/Main.kt`). +5. **Saved-state and typed-route parity improved.** + - Both hosts share `MeshtasticNavSavedStateConfig` from `core:navigation/commonMain` via `MultiBackstack`, reducing platform drift risk in serializer registration. +6. **Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`).** + +### Actions Taken + +- Renamed all JetBrains-forked lifecycle/nav3 version catalog aliases from `androidx-*` to `jetbrains-*` prefix to make fork provenance unambiguous: + - `jetbrains-lifecycle-runtime`, `jetbrains-lifecycle-runtime-compose`, `jetbrains-lifecycle-viewmodel-compose`, `jetbrains-lifecycle-viewmodel-navigation3` + - `jetbrains-navigation3-runtime`, `jetbrains-navigation3-ui` +- Documented in the version catalog that `jetbrains-navigation3-runtime` intentionally maps to `navigation3-ui` until a separate runtime artifact is published. +- Migrated `core:data` `commonMain` from `androidx.lifecycle:lifecycle-runtime` (Google) to `org.jetbrains.androidx.lifecycle:lifecycle-runtime` (JetBrains fork) for full consistency. +- Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`). +- Consolidated `app` adaptive dependencies to JetBrains Material 3 Adaptive coordinates (`org.jetbrains.compose.material3.adaptive:*`) so Android and Desktop consume the same adaptive artifact family. The Android-only navigation suite remains on `androidx.compose.material3:material3-adaptive-navigation-suite`. + +### Deferred Follow-ups + +- Add automated validation that desktop serializer registrations stay in sync with shared route keys. + +## Options Evaluated + +### Option A: Reuse `:app` navigation implementation directly in desktop + +**Pros** +- Maximum short-term parity in structure. + +**Cons** +- `:app` graph code is tightly coupled to Android wrappers (`Android*ViewModel`, Android-only screen wrappers, app-specific UI state like scroll-to-top flows). +- Pulling this code into desktop would either fail at compile-time or force additional platform branching in app files. +- Violates clean module boundaries (`desktop` should not depend on Android-specific app glue). + +**Decision:** Not recommended. + +### Option B: Keep fully separate desktop graph and replicate app behavior manually + +**Pros** +- Lowest refactor cost right now. +- Keeps platform customization simple. + +**Cons** +- Drift is guaranteed over time. +- No central policy for intentional vs accidental divergence. +- High maintenance burden for parity-sensitive flows. + +**Decision:** Not recommended as a long-term strategy. + +### Option C (Recommended): Hybrid shared contract + platform graph adapters + +**Pros** +- Preserves platform-specific wiring where needed. +- Reduces drift by moving parity-sensitive definitions to shared contracts. +- Enables explicit, testable exceptions for desktop-only or Android-only behavior. + +**Cons** +- Requires incremental extraction work. +- Needs light governance (parity matrix + tests + docs). + +**Decision:** Recommended. + +## Decision + +Adopt a **hybrid parity model**: + +1. Keep platform graph registration in `app` and `desktop`. +2. Extract parity-sensitive navigation metadata into shared contracts (top-level destination set/order, route ownership map, and allowed platform exceptions). +3. Keep platform-specific destination implementations as adapters around shared route keys. +4. Add route parity tests so drift is detected automatically. + +## Implementation Plan + +### Phase 1 (Immediate): Stop drift on shell structure ✅ + +- ✅ Aligned desktop top-level destination policy with Android (removed Firmware from top-level; kept as in-flow). +- ✅ Both shells now use shared `TopLevelDestination` enum from `core:navigation/commonMain`. +- ✅ Shared icon mapping in `core:ui` (`TopLevelDestinationExt.icon`). +- Parity matrix documented inline: top-level set is Conversations, Nodes, Map, Settings, Connections on both platforms. + +### Phase 2 (Near-term): Extract shared navigation contracts ✅ (partially) + +- ✅ Shared `TopLevelDestination` enum with `fromNavKey()` already serves as the canonical metadata object. +- Both `app` and `desktop` shells iterate `TopLevelDestination.entries` — no separate `DesktopDestination` enum remains. +- Remaining: optional visibility flags by platform, route grouping metadata (lower priority since shells are unified). + +### Phase 3 (Near-term): Add parity checks ✅ (partially) + +- ✅ `NavigationParityTest` in `core:navigation/commonTest` — asserts 5 top-level destinations and `fromNavKey` matching. +- ✅ `DesktopTopLevelDestinationParityTest` in `desktop/test` — asserts desktop routes match Android parity set and firmware is not top-level. +- Remaining: assert every desktop serializer registration corresponds to an actual route; assert every intentional exception is listed. + +### Phase 4 (Mid-term): Reduce app-specific graph coupling + +- Move reusable graph composition helpers out of `:app` where practical (while keeping Android-only wrappers in Android source sets). +- Keep desktop-specific placeholder implementations, but tie them to explicit parity exception entries. + +## Consequences + +- Navigation behavior remains platform-adaptive, but parity expectations become explicit and enforceable. +- Desktop can keep legitimate deviations (map/charts/platform integrations) without silently changing IA. +- New route additions will require touching one shared contract plus platform implementations, making review scope clearer. + +## Source Anchors + +- Shared routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` +- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt` +- Android shell: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt` +- Shared graph registrations: `feature/*/src/commonMain/kotlin/org/meshtastic/feature/*/navigation/` +- Platform graph content: `feature/*/src/{androidMain,jvmMain}/kotlin/org/meshtastic/feature/*/navigation/` +- Desktop shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` +- Desktop graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt` + + diff --git a/docs/kmp-status.md b/docs/kmp-status.md index 1e6552437..95e4b6945 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -1,6 +1,6 @@ # KMP Migration Status -> Last updated: 2026-04-15 +> Last updated: 2026-04-10 Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/). @@ -27,7 +27,7 @@ Modules that share JVM-specific code between Android and desktop now standardize | `core:database` | ✅ | ✅ | Room KMP | | `core:domain` | ✅ | ✅ | UseCases | | `core:prefs` | ✅ | ✅ | Preferences layer | -| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioTransport` | +| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface` | | `core:data` | ✅ | ✅ | Data orchestration | | `core:ble` | ✅ | ✅ | Kable multiplatform BLE abstractions in commonMain | | `core:nfc` | ✅ | ✅ | NFC contract in commonMain; hardware in androidMain | @@ -49,7 +49,7 @@ Modules that share JVM-specific code between Android and desktop now standardize | `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; fully shared `contactsGraph`, `MessageScreen`, `ContactsScreen`, and `MessageListPaged` | | `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection | | `feature:intro` | — | — | Screens remain in `androidMain`; shared ViewModel only | -| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` | +| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`, and `TracerouteNodeSelection`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` | | `feature:firmware` | ✅ | ✅ Fully KMP: Unified OTA, native Secure DFU, USB/UF2, FirmwareRetriever | | `feature:wifi-provision` | ✅ | ✅ KMP WiFi provisioning via BLE (Nymea protocol); shared UI and ViewModel | | `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. | @@ -79,7 +79,9 @@ Working Compose Desktop application with: | Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | | CI confidence | **9/10** | 26 modules validated (including feature:wifi-provision); native release installers automated | | DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features. SfppHasher, AddressUtils, formatString hex, and MetricFormatter edge cases newly covered. Gaps: `core:service`, `core:network` (TcpTransport), `core:ble` state machine, `core:ui` utils | +| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | + +> See [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md) for the full gap analysis. ## Completion Estimates @@ -103,20 +105,18 @@ Based on the latest codebase investigation, the following steps are proposed to | Decision | Status | Details | |---|---|---| -| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | Both shells use shared `TopLevelDestination` enum and `MeshtasticNavDisplay` from `core:ui/commonMain`; parity tests in `core:navigation/commonTest` | +| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | See [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md) | | Hilt → Koin | ✅ Done | See [`decisions/koin-migration.md`](./decisions/koin-migration.md) | | BLE abstraction (Kable) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) | | Firmware KMP migration (pure Secure DFU) | ✅ Done | Native Nordic Secure DFU protocol reimplemented in pure KMP using Kable; desktop is first-class target | -| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta02`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | +| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta01`; supports Large (1200dp) and Extra-large (1600dp) breakpoints | | JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime | -| Expect/actual consolidation | ✅ Done | 10+ pairs eliminated (including `formatString`, `CommonUri`, `SfppHasher`); ~20 genuinely platform-specific retained (Parcelable, DateFormatter, Database, Location, Composable UI primitives) | +| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained | | Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` | | **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. | | **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. On JVM/Desktop, inactive databases are explicitly closed on switch (Room KMP's `setAutoCloseTimeout` is Android-only), and `desktopDataDir()` in `core:database/jvmMain` is the single source for data directory resolution. | | Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants | -| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioTransport`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | -| URI unification | ✅ Done | `CommonUri` is a `typealias` to `com.eygraber.uri.Uri` (uri-kmp); `MeshtasticUri` wrapper deleted; bridge with `toAndroidUri()`/`toKmpUri()` | -| Utility commonization | ✅ Done | `formatString` → pure Kotlin parser in `commonMain`; `SfppHasher` and `CryptoCodec` → `Okio ByteString.sha256()`; `MetricFormatter` centralizes display strings (temperature, voltage, current, %, humidity, pressure, SNR, RSSI) | +| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `MeshtasticNavDisplay`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioInterface`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop | ## Navigation Parity Note @@ -127,7 +127,7 @@ Based on the latest codebase investigation, the following steps are proposed to - Firmware remains available as an in-flow route instead of a top-level destination, matching Android information architecture. - Android navigation graphs are decoupled and extracted into their respective feature modules, aligning with the Desktop architecture. - Parity tests exist in `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`). -- Remaining parity work: serializer registration validation and platform exception tracking. +- Remaining parity work is documented in [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md): serializer registration validation and platform exception tracking. ## App Module Thinning Status @@ -150,7 +150,7 @@ Extracted to shared `commonMain` (no longer app-only): Extracted to core KMP modules: - Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain` - USB/Serial radio connections → `core:network/androidMain` -- TCP radio connections, BLE radio connections (`BleRadioTransport`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations) +- TCP radio connections, BLE radio connections (`BleRadioInterface`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations) Remaining to be extracted from `:app` or unified in `commonMain`: - `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface. `MapViewProvider` interface simplified — track rendering and traceroute rendering extracted to dedicated provider contracts) @@ -159,20 +159,22 @@ Remaining to be extracted from `:app` or unified in `commonMain`: | Dependency | Version | Why | |---|---|---| -| Compose Multiplatform | `1.11.0-beta02` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha06` | -| Compose Multiplatform Material 3 | `1.11.0-alpha06` | Material 3 components including `NavigationSuiteScaffold` | -| Koin | `4.2.1` | Nav3 + K2 compiler plugin support | -| JetBrains Lifecycle | `2.11.0-alpha03` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels | -| JetBrains Navigation 3 | `1.1.0-rc01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs | +| Compose Multiplatform | `1.11.0-beta01` | Required for JetBrains Adaptive `1.3.0-alpha06` and Material 3 `1.11.0-alpha05` | +| Compose Multiplatform Material 3 | `1.11.0-alpha05` | Material 3 components including `NavigationSuiteScaffold` | +| Koin | `4.2.0` | Nav3 + K2 compiler plugin support | +| JetBrains Lifecycle | `2.11.0-alpha02` | Multiplatform ViewModel/lifecycle; includes `lifecycle-viewmodel-navigation3` for entry-scoped ViewModels | +| JetBrains Navigation 3 | `1.1.0-beta01` | Multiplatform navigation with Scene architecture, `NavEntry.metadata`, transition specs | | JetBrains Navigation Event | `1.1.0-alpha01` | KMP `NavigationBackHandler` for predictive back | | JetBrains Material 3 Adaptive | `1.3.0-alpha06` | `ListDetailPaneScaffold`, `ThreePaneScaffold`, Large/XL breakpoints | | Kable BLE | `0.42.0` | Provides fully multiplatform BLE support | **Policy:** Stable by default. RC when it unlocks KMP functionality. Alpha only behind hard abstraction seams. Do not downgrade CMP or Koin — they enable critical KMP features. +> See [`decisions/navigation3-api-alignment-2026-03.md`](./decisions/navigation3-api-alignment-2026-03.md) for the full Navigation 3 API surface audit and Scene architecture adoption plan. + ## References - Roadmap: [`docs/roadmap.md`](./roadmap.md) - Agent guide: [`AGENTS.md`](../AGENTS.md) -- Agent skills: [`.skills/`](../.skills/) +- Playbooks: [`docs/agent-playbooks/`](./agent-playbooks/) - Decision records: [`docs/decisions/`](./decisions/) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8cff42c1f..91d051f9f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,8 @@ # Roadmap -> Last updated: 2026-04-15 +> Last updated: 2026-04-10 -Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). +Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). For the full gap analysis, see [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md). ## Architecture Health (Immediate) @@ -18,8 +18,6 @@ These items address structural gaps identified in the March 2026 architecture re | Auto-wire Desktop ViewModels via K2 Compiler (eliminate manual wiring) | Medium | Low | ✅ | | **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ | | **iOS CI gate (compile-only validation)** | High | Medium | ✅ | -| **Commonize utilities** (`formatString`, `SfppHasher`, `CryptoCodec`, `CommonUri`) | High | Medium | ✅ | -| **Centralize metric formatting** (`MetricFormatter`) | Medium | Low | ✅ | ## Active Work @@ -59,7 +57,7 @@ These items address structural gaps identified in the March 2026 architecture re | TCP | Desktop (JVM) | ✅ Done — shared `StreamFrameCodec` + `TcpTransport` in `core:network` | | Serial/USB | Desktop (JVM) | ✅ Done — jSerialComm | | MQTT | All (KMP) | ✅ Completed — KMQTT in commonMain | -| BLE | All (KMP) | ✅ Done — Kable in `commonMain` (`BleRadioTransport`) | +| BLE | All (KMP) | ✅ Done — Kable in `commonMain` (`BleRadioInterface`) | ### Desktop Feature Gaps diff --git a/docs/testing/baseline_coverage.md b/docs/testing/baseline_coverage.md new file mode 100644 index 000000000..6445ea9e5 --- /dev/null +++ b/docs/testing/baseline_coverage.md @@ -0,0 +1,6 @@ +# Baseline Test Coverage Report +**Date:** Wednesday, March 18, 2026 +**Overall Project Coverage:** 8.796% +**App Module Coverage:** 1.6404% + +This baseline was captured using `./gradlew koverLog` at the start of the 'Expand Testing Coverage' track. \ No newline at end of file diff --git a/docs/testing/final_coverage.md b/docs/testing/final_coverage.md new file mode 100644 index 000000000..bc502d704 --- /dev/null +++ b/docs/testing/final_coverage.md @@ -0,0 +1,18 @@ +# Final Test Coverage Report +**Date:** Wednesday, March 18, 2026 +**Overall Project Coverage:** 10.2591% (Baseline: 8.796%) +**Absolute Increase:** +1.46% + +## Module Highlights +| Module | Coverage | Notes | +| :--- | :--- | :--- | +| `core:domain` | 26.55% | UseCase gap fill complete. | +| `feature:intro` | 30.76% | ViewModel tests enabled. | +| `feature:map` | 33.33% | BaseMapViewModel tests refactored. | +| `feature:node` | 24.70% | Metrics, Detail, Compass, and Filter tests added/refactored. | +| `feature:connections` | 26.49% | ScannerViewModel verified. | +| `feature:messaging` | 18.54% | MessageViewModel verified. | + +This report concludes the 'Expand Testing Coverage' track. +Significant improvements were made in ViewModel testability through interface extraction and Mokkery/Turbine migration. +Foundational logic in `core:network` was strengthened with Kotest property-based tests. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/changelogs/default.txt b/fastlane/metadata/android/fr-FR/changelogs/default.txt index a322da020..0553de284 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/default.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/default.txt @@ -1 +1 @@ -Pour des notes de version détaillées, veuillez visiter : https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file diff --git a/fastlane/metadata/android/ro-RO/changelogs/default.txt b/fastlane/metadata/android/ro-RO/changelogs/default.txt index b254b55b8..0553de284 100644 --- a/fastlane/metadata/android/ro-RO/changelogs/default.txt +++ b/fastlane/metadata/android/ro-RO/changelogs/default.txt @@ -1 +1 @@ -Pentru note detaliate pentru versiuni, vizitați: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file +For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/ \ No newline at end of file diff --git a/fastlane/metadata/android/ro-RO/short_description.txt b/fastlane/metadata/android/ro-RO/short_description.txt index f6c7d5664..e3f0988db 100644 --- a/fastlane/metadata/android/ro-RO/short_description.txt +++ b/fastlane/metadata/android/ro-RO/short_description.txt @@ -1 +1 @@ -Aplicația oficială pentru Meshtastic, un radio open, off-grid, mess. \ No newline at end of file +The official app for Meshtastic, an open-source, off-grid, mesh radio. \ No newline at end of file diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt index b6999aadc..b0a3d738c 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt @@ -60,14 +60,7 @@ class AndroidGetDiscoveredDevicesUseCase( override fun invoke(showMock: Boolean): Flow { val nodeDb = nodeRepository.nodeDBbyNum - // Filter out non-Meshtastic peripherals (headphones, cars, watches, etc.). - // BluetoothAdapter.bondedDevices returns every bonded device on the phone, so we - // must restrict the picker to entries whose advertised name matches the - // Meshtastic firmware pattern (see MeshtasticBleConstants.BLE_NAME_PATTERN). - val bondedBleFlow = - bluetoothRepository.state.map { ble -> - ble.bondedDevices.filter { it.getMeshtasticShortName() != null }.map { DeviceListEntry.Ble(it) } - } + val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } } val processedTcpFlow = combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 7e57f2eff..e4bb00c6b 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -28,7 +29,9 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.model.RadioController @@ -36,7 +39,6 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase @@ -52,8 +54,8 @@ open class ScannerViewModel( private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers, private val bleScanner: org.meshtastic.core.ble.BleScanner? = null, ) : ViewModel() { - private val _showMockTransport = MutableStateFlow(false) - val showMockTransport: StateFlow = _showMockTransport.asStateFlow() + private val _showMockInterface = MutableStateFlow(false) + val showMockInterface: StateFlow = _showMockInterface.asStateFlow() private val _errorText = MutableStateFlow(null) val errorText: StateFlow = _errorText.asStateFlow() @@ -66,7 +68,7 @@ open class ScannerViewModel( private var scanJob: kotlinx.coroutines.Job? = null init { - _showMockTransport.value = radioInterfaceService.isMockTransport() + _showMockInterface.value = radioInterfaceService.isMockInterface() } fun startBleScan() { @@ -75,24 +77,25 @@ open class ScannerViewModel( isBleScanningState.value = true scannedBleDevices.value = emptyMap() - scanJob = - safeLaunch(tag = "startBleScan") { - try { - bleScanner - .scan( - timeout = kotlin.time.Duration.INFINITE, - serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID, - ) - .flowOn(dispatchers.io) - .collect { device -> - if (!scannedBleDevices.value.containsKey(device.address)) { - scannedBleDevices.update { current -> current + (device.address to device) } - } + scanJob = viewModelScope.launch { + try { + bleScanner + .scan( + timeout = kotlin.time.Duration.INFINITE, + serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID, + ) + .flowOn(dispatchers.io) + .collect { device -> + if (!scannedBleDevices.value.containsKey(device.address)) { + scannedBleDevices.update { current -> current + (device.address to device) } } - } finally { - isBleScanningState.value = false - } + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + co.touchlab.kermit.Logger.w(e) { "BLE scan failed" } + } finally { + isBleScanningState.value = false } + } } fun stopBleScan() { @@ -102,9 +105,9 @@ open class ScannerViewModel( } private val discoveredDevicesFlow = - showMockTransport + showMockInterface .flatMapLatest { showMock -> getDiscoveredDevicesUseCase.invoke(showMock) } - .stateInWhileSubscribed(initialValue = null) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) /** A combined list of bonded and scanned BLE devices for the UI. */ val bleDevicesForUi: StateFlow> = @@ -127,7 +130,7 @@ open class ScannerViewModel( } .flowOn(dispatchers.default) .distinctUntilChanged() - .stateInWhileSubscribed(initialValue = emptyList()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) /** UI StateFlow for USB devices. */ val usbDevicesForUi: StateFlow> = @@ -183,11 +186,11 @@ open class ScannerViewModel( fun addRecentAddress(address: String, name: String) { if (!address.startsWith("t")) return - safeLaunch(tag = "addRecentAddress") { recentAddressesDataSource.add(RecentAddress(address, name)) } + viewModelScope.launch { recentAddressesDataSource.add(RecentAddress(address, name)) } } fun removeRecentAddress(address: String) { - safeLaunch(tag = "removeRecentAddress") { recentAddressesDataSource.remove(address) } + viewModelScope.launch { recentAddressesDataSource.remove(address) } } /** @@ -202,7 +205,6 @@ open class ScannerViewModel( changeDeviceAddress(it.fullAddress) true } else { - radioPrefs.setDevName(it.name) requestBonding(it) false } @@ -213,13 +215,12 @@ open class ScannerViewModel( changeDeviceAddress(it.fullAddress) true } else { - radioPrefs.setDevName(it.name) requestPermission(it) false } } is DeviceListEntry.Tcp -> { - safeLaunch(tag = "onSelectedTcp") { + viewModelScope.launch { radioPrefs.setDevName(it.name) addRecentAddress(it.fullAddress, it.name) changeDeviceAddress(it.fullAddress) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt index 4249cd625..ecdaeb3c3 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/CommonGetDiscoveredDevicesUseCase.kt @@ -18,15 +18,14 @@ 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 @@ -50,7 +49,7 @@ class CommonGetDiscoveredDevicesUseCase( tcpServices, recentList, -> - val defaultName = safeCatchingAll { getStringSuspend(Res.string.meshtastic) }.getOrDefault("Meshtastic") + val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic") processTcpServices(tcpServices, recentList, defaultName) } @@ -72,7 +71,7 @@ class CommonGetDiscoveredDevicesUseCase( usbList + if (showMock) { val demoModeLabel = - safeCatchingAll { getStringSuspend(Res.string.demo_mode) }.getOrDefault("Demo Mode") + runCatching { getString(Res.string.demo_mode) }.getOrDefault("Demo Mode") listOf(DeviceListEntry.Mock(demoModeLabel)) } else { emptyList() diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index c6962c8c0..152e880cb 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -32,8 +32,8 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) ConnectionsScreen( scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, - onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, + onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, onConfigNavigate = { route -> backStack.add(route) }, ) } @@ -42,8 +42,8 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) ConnectionsScreen( scanModel = koinViewModel(), radioConfigViewModel = koinViewModel(), - onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, - onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, + onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, + onNavigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, onConfigNavigate = { route -> backStack.add(route) }, ) } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 7fdc287cd..828b7be2f 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -151,7 +151,7 @@ fun ConnectionsScreen( MainAppBar( title = stringResource(Res.string.connections), ourNode = ourNode, - showNodeChip = ourNode != null && connectionState is ConnectionState.Connected, + showNodeChip = ourNode != null && connectionState.isConnected(), canNavigateUp = false, onNavigateUp = {}, actions = {}, @@ -167,19 +167,17 @@ fun ConnectionsScreen( Spacer(modifier = Modifier.height(4.dp)) val uiState = when { - connectionState is ConnectionState.Connected && ourNode != null -> - ConnectionUiState.CONNECTED_WITH_NODE - - connectionState is ConnectionState.Connected || + connectionState.isConnected() && ourNode != null -> 2 + connectionState.isConnected() || connectionState == ConnectionState.Connecting || - selectedDevice != NO_DEVICE_SELECTED -> ConnectionUiState.CONNECTING + selectedDevice != NO_DEVICE_SELECTED -> 1 - else -> ConnectionUiState.NO_DEVICE + else -> 0 } Crossfade(targetState = uiState, label = "connection_state") { state -> when (state) { - ConnectionUiState.CONNECTED_WITH_NODE -> + 2 -> ConnectedDeviceContent( ourNode = ourNode, regionUnset = regionUnset, @@ -193,7 +191,7 @@ fun ConnectionsScreen( }, ) - ConnectionUiState.CONNECTING -> + 1 -> ConnectingDeviceContent( connectionState = connectionState, selectedDevice = selectedDevice, @@ -210,9 +208,7 @@ fun ConnectionsScreen( } var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) } - LaunchedEffect(selectedDevice) { - DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } - } + LaunchedEffect(Unit) { DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } } val supportedDeviceTypes = scanModel.supportedDeviceTypes @@ -373,15 +369,3 @@ private fun NoDeviceContent() { ) } } - -/** Visual state for the connection screen's [Crossfade] animation. */ -private enum class ConnectionUiState { - /** No device is selected. */ - NO_DEVICE, - - /** A device is selected or we are actively connecting. */ - CONNECTING, - - /** Connected with node info available. */ - CONNECTED_WITH_NODE, -} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt index 0d079ebdc..9907e01c0 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/ConnectingDeviceInfo.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState @@ -42,10 +41,6 @@ import org.meshtastic.core.resources.connecting import org.meshtastic.core.resources.disconnect import org.meshtastic.core.ui.theme.StatusColors.StatusRed -/** - * Displays the currently connecting (or connected) device with its name, address, connection status, and a disconnect - * button. - */ @Composable fun ConnectingDeviceInfo( connectionState: ConnectionState, @@ -55,7 +50,7 @@ fun ConnectingDeviceInfo( modifier: Modifier = Modifier, ) { val statusText = - if (connectionState is ConnectionState.Connected) { + if (connectionState.isConnected()) { stringResource(Res.string.connected) } else { stringResource(Res.string.connecting) @@ -80,8 +75,8 @@ fun ConnectingDeviceInfo( } Button( - shape = RectangleShape, - modifier = Modifier.fillMaxWidth().height(40.dp), + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.StatusRed, @@ -89,7 +84,7 @@ fun ConnectingDeviceInfo( ), onClick = onClickDisconnect, ) { - Text(stringResource(Res.string.disconnect)) + Text(stringResource(Res.string.disconnect), style = MaterialTheme.typography.titleMedium) } } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt index 8f5347e01..57f06e225 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt @@ -39,8 +39,6 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import org.jetbrains.compose.resources.stringResource @@ -77,11 +75,8 @@ fun CurrentlyConnectedInfo( while (bleDevice.device.isConnected) { try { rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() } - } catch (_: TimeoutCancellationException) { - Logger.d { "RSSI read timed out" } - } catch (e: CancellationException) { - throw e } catch (e: Exception) { + // RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise Logger.d(e) { "Failed to read RSSI ${e.message}" } } delay(RSSI_DELAY.seconds) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index 14f4dc42b..7071c18c9 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -17,13 +17,13 @@ package org.meshtastic.feature.connections.ui.components import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.selection.selectable import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -36,20 +36,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.action_select_device import org.meshtastic.core.resources.add import org.meshtastic.core.resources.bluetooth import org.meshtastic.core.resources.network @@ -80,20 +75,24 @@ fun DeviceListItem( ) { // Throttle the RSSI updates to match the connected device polling rate var displayedRssi by remember { mutableIntStateOf(rssi ?: 0) } - val currentRssi by rememberUpdatedState(rssi) + LaunchedEffect(rssi) { + if (displayedRssi == 0) { + displayedRssi = rssi ?: 0 + } + } LaunchedEffect(Unit) { while (true) { delay(RSSI_UPDATE_RATE_MS) - displayedRssi = currentRssi ?: 0 + displayedRssi = rssi ?: 0 } } val icon = when (device) { is DeviceListEntry.Ble -> - if (connectionState is ConnectionState.Connected) { + if (connectionState.isConnected()) { MeshtasticIcons.BluetoothConnected - } else if (connectionState is ConnectionState.Connecting) { + } else if (connectionState.isConnecting()) { MeshtasticIcons.BluetoothSearching } else { MeshtasticIcons.Bluetooth @@ -112,19 +111,11 @@ fun DeviceListItem( is DeviceListEntry.Mock -> stringResource(Res.string.add) } - val selectLabel = stringResource(Res.string.action_select_device) - val isSelected = connectionState is ConnectionState.Connected val clickableModifier = if (onDelete != null) { - Modifier.semantics { selected = isSelected } - .combinedClickable( - onClickLabel = selectLabel, - role = Role.RadioButton, - onClick = onSelect, - onLongClick = onDelete, - ) + Modifier.combinedClickable(onClick = onSelect, onLongClick = onDelete) } else { - Modifier.selectable(selected = isSelected, role = Role.RadioButton, onClick = onSelect) + Modifier.clickable(onClick = onSelect) } ListItem( @@ -141,7 +132,7 @@ fun DeviceListItem( contentDescription = contentDescription, modifier = Modifier.size(32.dp), tint = - if (connectionState is ConnectionState.Connected) { + if (connectionState.isConnected()) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -155,10 +146,10 @@ fun DeviceListItem( Rssi(rssi = displayedRssi) } - if (connectionState is ConnectionState.Connecting) { + if (connectionState.isConnecting()) { CircularProgressIndicator(modifier = Modifier.size(32.dp)) } else { - RadioButton(selected = connectionState is ConnectionState.Connected, onClick = null) + RadioButton(selected = connectionState.isConnected(), onClick = null) } } }, diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt index 04e9ac03e..6f291d68a 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt @@ -53,7 +53,7 @@ class ScannerViewModelTest { @BeforeTest fun setUp() { - every { radioInterfaceService.isMockTransport() } returns false + every { radioInterfaceService.isMockInterface() } returns false every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null) every { radioInterfaceService.supportedDeviceTypes } returns emptyList() diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index a1b35c797..c654e6e6f 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -59,5 +59,13 @@ kotlin { androidMain.dependencies { implementation(libs.markdown.renderer.android) } commonTest.dependencies { implementation(projects.core.testing) } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.androidx.test.ext.junit) + } + } } } diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index 3fa26d1cd..1647a5af7 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -18,7 +18,6 @@ package org.meshtastic.feature.firmware import android.content.Context import co.touchlab.kermit.Logger -import com.eygraber.uri.toAndroidUri import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.head @@ -33,6 +32,7 @@ import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.common.util.toPlatformUri import org.meshtastic.core.model.DeviceHardware import java.io.File import java.io.FileOutputStream @@ -188,7 +188,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien if (!tempDir.exists()) tempDir.mkdirs() try { - val platformUri = uri.toAndroidUri() + val platformUri = uri.toPlatformUri() as android.net.Uri val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null ZipInputStream(inputStream).use { zipInput -> var entry = zipInput.nextEntry @@ -225,9 +225,9 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) { file.toLocalFileOrNull()?.takeIf { it.exists() }?.length() - ?: context.contentResolver.openAssetFileDescriptor(file.uri.toAndroidUri(), "r")?.use { descriptor -> - descriptor.length.takeIf { it >= 0L } - } + ?: context.contentResolver + .openAssetFileDescriptor(file.uri.toPlatformUri() as android.net.Uri, "r") + ?.use { descriptor -> descriptor.length.takeIf { it >= 0L } } ?: 0L } @@ -242,13 +242,16 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien if (localFile != null && localFile.exists()) { localFile.readBytes() } else { - context.contentResolver.openInputStream(artifact.uri.toAndroidUri())?.use { it.readBytes() } - ?: throw IOException("Cannot open artifact: ${artifact.uri}") + context.contentResolver.openInputStream(artifact.uri.toPlatformUri() as android.net.Uri)?.use { + it.readBytes() + } ?: throw IOException("Cannot open artifact: ${artifact.uri}") } } override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = withContext(ioDispatcher) { - val inputStream = context.contentResolver.openInputStream(uri.toAndroidUri()) ?: return@withContext null + val inputStream = + context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri) + ?: return@withContext null val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin") tempFile.parentFile?.mkdirs() inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } @@ -279,10 +282,10 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien withContext(ioDispatcher) { val inputStream = source.toLocalFileOrNull()?.inputStream() - ?: context.contentResolver.openInputStream(source.uri.toAndroidUri()) + ?: context.contentResolver.openInputStream(source.uri.toPlatformUri() as android.net.Uri) ?: throw IOException("Cannot open source URI") val outputStream = - context.contentResolver.openOutputStream(destinationUri.toAndroidUri()) + context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri) ?: throw IOException("Cannot open content URI for writing") inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt index 1dcb7ba69..64d550a79 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -17,7 +17,6 @@ package org.meshtastic.feature.firmware import co.touchlab.kermit.Logger -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import org.koin.core.annotation.Single import org.meshtastic.core.database.entity.FirmwareRelease @@ -30,11 +29,7 @@ private const val FIRMWARE_BASE_URL = "https://raw.githubusercontent.com/meshtas /** OTA partition role in .mt.json manifests — the main application firmware. */ private const val OTA_PART_NAME = "app0" -@OptIn(ExperimentalSerializationApi::class) -private val manifestJson = Json { - ignoreUnknownKeys = true - exceptionsWithDebugInfo = false -} +private val manifestJson = Json { ignoreUnknownKeys = true } /** Retrieves firmware files, either by direct download or by extracting from a release asset zip. */ @Single diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index 1b5c0c803..0a051fa9c 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -35,18 +35,15 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -163,7 +160,9 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView uri?.let { viewModel.startUpdateFromFile(it) } } - val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) } + val saveFileLauncher = rememberSaveFileLauncher { meshtasticUri -> + viewModel.saveDfuFile(CommonUri.parse(meshtasticUri.uriString)) + } val actions = remember(viewModel, onNavigateUp) { @@ -382,35 +381,24 @@ private fun ReadyState( Spacer(Modifier.height(16.dp)) if (selectedReleaseType == FirmwareReleaseType.LOCAL) { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - val largeHeight = ButtonDefaults.LargeContainerHeight - @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) showDisclaimer = true }, - shapes = ButtonDefaults.shapesFor(largeHeight), - modifier = Modifier.fillMaxWidth().height(largeHeight), + modifier = Modifier.fillMaxWidth().height(56.dp), ) { Icon(MeshtasticIcons.Folder, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text( - stringResource(Res.string.firmware_update_select_file), - style = ButtonDefaults.textStyleFor(largeHeight), - ) + Text(stringResource(Res.string.firmware_update_select_file)) } } else if (state.release != null) { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - val largeHeight = ButtonDefaults.LargeContainerHeight - @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) showDisclaimer = true }, - shapes = ButtonDefaults.shapesFor(largeHeight), - modifier = Modifier.fillMaxWidth().height(largeHeight), + modifier = Modifier.fillMaxWidth().height(56.dp), ) { Icon( imageVector = @@ -428,7 +416,6 @@ private fun ReadyState( resource = Res.string.firmware_update_method_detail, stringResource(state.updateMethod.description), ), - style = ButtonDefaults.textStyleFor(largeHeight), ) } Spacer(Modifier.height(24.dp)) @@ -693,8 +680,7 @@ private fun ProgressContent( tint = MaterialTheme.colorScheme.primary, ) } else { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - CircularWavyProgressIndicator( + CircularProgressIndicator( progress = { if (isUpdating) progressState.progress else 1f }, modifier = Modifier.size(64.dp), ) @@ -722,8 +708,7 @@ private fun ProgressContent( Spacer(Modifier.height(12.dp)) if (isDownloading || isUpdating) { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - LinearWavyProgressIndicator( + LinearProgressIndicator( progress = { progressState.progress }, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), ) @@ -865,15 +850,8 @@ private fun SuccessState(onDone: () -> Unit) { textAlign = TextAlign.Center, ) Spacer(Modifier.height(32.dp)) - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - val largeHeight = ButtonDefaults.LargeContainerHeight - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - Button( - onClick = onDone, - shapes = ButtonDefaults.shapesFor(largeHeight), - modifier = Modifier.fillMaxWidth().height(largeHeight), - ) { - Text(stringResource(Res.string.firmware_update_done), style = ButtonDefaults.textStyleFor(largeHeight)) + Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text(stringResource(Res.string.firmware_update_done)) } } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index f8ff9fcac..b82e26432 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -35,9 +35,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.di.ApplicationCoroutineScope import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.FirmwareReleaseType import org.meshtastic.core.datastore.BootloaderWarningDataSource @@ -92,7 +90,6 @@ class FirmwareUpdateViewModel( private val firmwareUpdateManager: FirmwareUpdateManager, private val usbManager: FirmwareUsbManager, private val fileHandler: FirmwareFileHandler, - private val applicationScope: ApplicationCoroutineScope, ) : ViewModel() { private val _state = MutableStateFlow(FirmwareUpdateState.Idle) @@ -126,10 +123,9 @@ class FirmwareUpdateViewModel( override fun onCleared() { super.onCleared() - // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the - // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup - // running even if something tries to cancel it mid-flight. - applicationScope.launch(NonCancellable) { + // viewModelScope is already cancelled when onCleared() runs, so use a standalone scope + // for fire-and-forget cleanup of temporary firmware files. + kotlinx.coroutines.CoroutineScope(NonCancellable).launch { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } } @@ -151,7 +147,7 @@ class FirmwareUpdateViewModel( updateJob = viewModelScope.launch { _state.value = FirmwareUpdateState.Checking - safeCatching { + runCatching { val ourNode = nodeRepository.myNodeInfo.value val address = radioPrefs.devAddr.value?.drop(1) if (address == null || ourNode == null) { @@ -204,6 +200,7 @@ class FirmwareUpdateViewModel( } } .onFailure { e -> + if (e is CancellationException) throw e Logger.e(e) { "Error checking for updates" } val unknownError = UiText.Resource(Res.string.firmware_update_unknown_error) _state.value = @@ -393,7 +390,7 @@ private suspend fun cleanupTemporaryFiles( fileHandler: FirmwareFileHandler, tempFirmwareFile: FirmwareArtifact?, ): FirmwareArtifact? { - safeCatching { + runCatching { tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) } fileHandler.cleanupAllTemporaryFiles() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt index 40c6ad904..7980ad96a 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt @@ -17,7 +17,6 @@ package org.meshtastic.feature.firmware.navigation import androidx.compose.runtime.Composable -import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -28,12 +27,8 @@ import org.meshtastic.feature.firmware.FirmwareUpdateViewModel /** Registers the firmware update screen entries into the Navigation 3 entry provider. */ fun EntryProviderScope.firmwareGraph(backStack: NavBackStack) { - entry { - FirmwareScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) - } - entry { - FirmwareScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) - } + entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } + entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) } } @Composable diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt index 8035774c4..9d2478f45 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt @@ -20,6 +20,7 @@ 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 @@ -37,17 +38,13 @@ import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC -import org.meshtastic.core.common.util.safeCatching -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds /** BLE transport implementation for ESP32 Unified OTA protocol using Kable. */ class BleOtaTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher, + dispatcher: CoroutineDispatcher = Dispatchers.Default, ) : UnifiedOtaProtocol { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) @@ -71,16 +68,16 @@ class BleOtaTransport( tag = "BLE OTA", serviceUuid = OTA_SERVICE_UUID, retryCount = SCAN_RETRY_COUNT, - retryDelay = SCAN_RETRY_DELAY, + retryDelayMs = SCAN_RETRY_DELAY_MS, ) { it.address in targetAddresses } } @Suppress("MagicNumber") - override suspend fun connect(): Result = safeCatching { - Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." } - delay(REBOOT_DELAY) + override suspend fun connect(): Result = runCatching { + Logger.i { "BLE OTA: Waiting ${REBOOT_DELAY_MS}ms for device to reboot into OTA mode..." } + delay(REBOOT_DELAY_MS) Logger.i { "BLE OTA: Connecting to $address using Kable..." } @@ -99,7 +96,7 @@ class BleOtaTransport( .launchIn(transportScope) try { - val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) + val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS) if (finalState is BleConnectionState.Disconnected) { Logger.w { "BLE OTA: Failed to connect to ${device.address} (state=$finalState)" } throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${device.address}") @@ -140,7 +137,7 @@ class BleOtaTransport( .launchIn(this) // Allow time for the BLE subscription to be established before proceeding. - delay(SUBSCRIPTION_SETTLE) + delay(SUBSCRIPTION_SETTLE_MS) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -152,14 +149,14 @@ class BleOtaTransport( sizeBytes: Long, sha256Hash: String, onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, - ): Result = safeCatching { + ): Result = runCatching { val command = OtaCommand.StartOta(sizeBytes, sha256Hash) val packetsSent = sendCommand(command) var handshakeComplete = false var responsesReceived = 0 while (!handshakeComplete) { - val response = waitForResponse(ERASING_TIMEOUT) + val response = waitForResponse(ERASING_TIMEOUT_MS) responsesReceived++ when (val parsed = OtaResponse.parse(response)) { is OtaResponse.Ok -> { @@ -189,7 +186,7 @@ class BleOtaTransport( data: ByteArray, chunkSize: Int, onProgress: suspend (Float) -> Unit, - ): Result = safeCatching { + ): Result = runCatching { val totalBytes = data.size var sentBytes = 0 @@ -206,7 +203,7 @@ class BleOtaTransport( val nextSentBytes = sentBytes + currentChunkSize repeat(packetsSentForChunk) { i -> - val response = waitForResponse(ACK_TIMEOUT) + val response = waitForResponse(ACK_TIMEOUT_MS) val isLastPacketOfChunk = i == packetsSentForChunk - 1 when (val parsed = OtaResponse.parse(response)) { @@ -215,7 +212,7 @@ class BleOtaTransport( if (nextSentBytes >= totalBytes && isLastPacketOfChunk) { sentBytes = nextSentBytes onProgress(1.0f) - return@safeCatching Unit + return@runCatching Unit } } is OtaResponse.Error -> { @@ -232,7 +229,7 @@ class BleOtaTransport( onProgress(sentBytes.toFloat() / totalBytes) } - val finalResponse = waitForResponse(VERIFICATION_TIMEOUT) + val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS) when (val parsed = OtaResponse.parse(finalResponse)) { is OtaResponse.Ok -> Unit is OtaResponse.Error -> { @@ -277,21 +274,21 @@ class BleOtaTransport( return packetsSent } - private suspend fun waitForResponse(timeout: Duration): String = try { - withTimeout(timeout) { responseChannel.receive() } + private suspend fun waitForResponse(timeoutMs: Long): String = try { + withTimeout(timeoutMs) { responseChannel.receive() } } catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) { - throw OtaProtocolException.Timeout("Timeout waiting for response after $timeout") + throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms") } companion object { - private val CONNECTION_TIMEOUT = 15.seconds - private val SUBSCRIPTION_SETTLE = 500.milliseconds - private val ERASING_TIMEOUT = 60.seconds - private val ACK_TIMEOUT = 10.seconds - private val VERIFICATION_TIMEOUT = 10.seconds - private val REBOOT_DELAY = 5.seconds + private const val CONNECTION_TIMEOUT_MS = 15_000L + private const val SUBSCRIPTION_SETTLE_MS = 500L + private const val ERASING_TIMEOUT_MS = 60_000L + private const val ACK_TIMEOUT_MS = 10_000L + private const val VERIFICATION_TIMEOUT_MS = 10_000L + private const val REBOOT_DELAY_MS = 5_000L private const val SCAN_RETRY_COUNT = 3 - private val SCAN_RETRY_DELAY = 2.seconds + private const val SCAN_RETRY_DELAY_MS = 2_000L const val RECOMMENDED_CHUNK_SIZE = 512 } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt index fa9966b66..6df54ea43 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleScanSupport.kt @@ -26,7 +26,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds internal const val DEFAULT_SCAN_RETRY_COUNT = 3 -internal val DEFAULT_SCAN_RETRY_DELAY: Duration = 2.seconds +internal const val DEFAULT_SCAN_RETRY_DELAY_MS = 2_000L internal val DEFAULT_SCAN_TIMEOUT: Duration = 10.seconds private const val MAC_PARTS_COUNT = 6 @@ -45,7 +45,7 @@ internal fun calculateMacPlusOne(macAddress: String): String { if (parts.size != MAC_PARTS_COUNT) return macAddress val lastByte = parts[MAC_PARTS_COUNT - 1].toIntOrNull(HEX_RADIX) ?: return macAddress val incremented = ((lastByte + 1) and BYTE_MASK).toString(HEX_RADIX).uppercase().padStart(2, '0') - return "${parts.take(MAC_PARTS_COUNT - 1).joinToString(":")}:$incremented" + return parts.take(MAC_PARTS_COUNT - 1).joinToString(":") + ":" + incremented } /** @@ -59,7 +59,7 @@ internal suspend fun scanForBleDevice( tag: String, serviceUuid: kotlin.uuid.Uuid, retryCount: Int = DEFAULT_SCAN_RETRY_COUNT, - retryDelay: Duration = DEFAULT_SCAN_RETRY_DELAY, + retryDelayMs: Long = DEFAULT_SCAN_RETRY_DELAY_MS, scanTimeout: Duration = DEFAULT_SCAN_TIMEOUT, predicate: (BleDevice) -> Boolean, ): BleDevice? { @@ -80,7 +80,7 @@ internal suspend fun scanForBleDevice( return device } Logger.w { "$tag: Target not in ${foundDevices.size} devices found" } - if (attempt < retryCount - 1) delay(retryDelay) + if (attempt < retryCount - 1) delay(retryDelayMs) } return null } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 82e91413d..58c09f16a 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -27,7 +27,6 @@ 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 @@ -68,7 +67,7 @@ private const val GATT_RELEASE_DELAY_MS = 1000L * * All platform I/O (file reading, content-resolver imports) is delegated to [FirmwareFileHandler]. */ -@Suppress("TooManyFunctions", "LongParameterList") +@Suppress("TooManyFunctions") @Single class Esp32OtaUpdateHandler( private val firmwareRetriever: FirmwareRetriever, @@ -77,7 +76,6 @@ 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). */ @@ -104,7 +102,7 @@ class Esp32OtaUpdateHandler( hardware = hardware, updateState = updateState, firmwareUri = firmwareUri, - transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatchers.default) }, + transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address) }, rebootMode = 1, connectionAttempts = 5, ) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt index d21cc15ea..3694c4e6a 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt @@ -32,7 +32,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.meshtastic.core.common.util.ioDispatcher -import org.meshtastic.core.common.util.safeCatching /** * WiFi/TCP transport implementation for ESP32 Unified OTA protocol. @@ -55,7 +54,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In /** Connect to the device via TCP using Ktor raw sockets. */ override suspend fun connect(): Result = withContext(ioDispatcher) { - safeCatching { + runCatching { Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" } val selector = SelectorManager(ioDispatcher) @@ -83,7 +82,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In sizeBytes: Long, sha256Hash: String, onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, - ): Result = safeCatching { + ): Result = runCatching { val command = OtaCommand.StartOta(sizeBytes, sha256Hash) sendCommand(command) @@ -117,7 +116,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In chunkSize: Int, onProgress: suspend (Float) -> Unit, ): Result = withContext(ioDispatcher) { - safeCatching { + runCatching { if (!isConnected) { throw OtaProtocolException.TransferFailed("Not connected") } @@ -167,7 +166,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In override suspend fun close() { withContext(ioDispatcher) { - safeCatching { + runCatching { socket?.close() selectorManager?.close() } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt index 43f6804e1..10a0a5154 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt @@ -21,11 +21,7 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecodingException -@OptIn(ExperimentalSerializationApi::class) -private val json = Json { - ignoreUnknownKeys = true - exceptionsWithDebugInfo = false -} +private val json = Json { ignoreUnknownKeys = true } /** * Parse pre-extracted zip entries into a [DfuZipPackage]. diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index a2eb5a7a4..3e673461b 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -28,7 +28,6 @@ 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 @@ -71,7 +70,6 @@ class SecureDfuHandler( private val radioController: RadioController, private val bleScanner: BleScanner, private val bleConnectionFactory: BleConnectionFactory, - private val dispatchers: CoroutineDispatchers, ) : FirmwareUpdateHandler { @Suppress("LongMethod") @@ -110,7 +108,7 @@ class SecureDfuHandler( var transport: SecureDfuTransport? = null var completed = false try { - transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target, dispatchers.default) + transport = SecureDfuTransport(bleScanner, bleConnectionFactory, target) transport.triggerButtonlessDfu().onFailure { e -> Logger.w(e) { "DFU: Buttonless trigger failed ($e) — device may already be in DFU mode" } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt index 42e92c8ac..f3d9d8648 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt @@ -30,6 +30,7 @@ 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 @@ -45,12 +46,8 @@ import org.meshtastic.core.ble.BleDevice import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BleWriteType import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH -import org.meshtastic.core.common.util.safeCatching import org.meshtastic.feature.firmware.ota.calculateMacPlusOne import org.meshtastic.feature.firmware.ota.scanForBleDevice -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds /** * Kable-based transport for the Nordic Secure DFU (Secure DFU over BLE) protocol. @@ -66,7 +63,7 @@ class SecureDfuTransport( private val scanner: BleScanner, connectionFactory: BleConnectionFactory, private val address: String, - dispatcher: CoroutineDispatcher, + dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { private val transportScope = CoroutineScope(SupervisorJob() + dispatcher) private val bleConnection = connectionFactory.create(transportScope, "Secure DFU") @@ -91,7 +88,7 @@ class SecureDfuTransport( * * The caller must have already released the mesh-service BLE connection before calling this. */ - suspend fun triggerButtonlessDfu(): Result = safeCatching { + suspend fun triggerButtonlessDfu(): Result = runCatching { Logger.i { "DFU: Scanning for device $address to trigger buttonless DFU..." } val device = @@ -99,7 +96,7 @@ class SecureDfuTransport( ?: throw DfuException.ConnectionFailed("Device $address not found for buttonless DFU trigger") Logger.i { "DFU: Connecting to $address to trigger buttonless DFU..." } - bleConnection.connectAndAwait(device, CONNECT_TIMEOUT) + bleConnection.connectAndAwait(device, CONNECT_TIMEOUT_MS) bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val buttonlessChar = service.characteristic(SecureDfuUuids.BUTTONLESS_NO_BONDS) @@ -114,7 +111,7 @@ class SecureDfuTransport( .catch { e -> Logger.d(e) { "DFU: Buttonless indication stream ended (expected on disconnect)" } } .launchIn(this) - delay(SUBSCRIPTION_SETTLE) + delay(SUBSCRIPTION_SETTLE_MS) Logger.i { "DFU: Writing buttonless DFU trigger..." } service.write(buttonlessChar, byteArrayOf(0x01), BleWriteType.WITH_RESPONSE) @@ -122,7 +119,7 @@ class SecureDfuTransport( // Wait for the indication response (0x20-01-STATUS). The device may disconnect before we receive it — // that's expected and treated as success, matching the Nordic DFU library's behavior. try { - withTimeout(BUTTONLESS_RESPONSE_TIMEOUT) { + withTimeout(BUTTONLESS_RESPONSE_TIMEOUT_MS) { val response = indicationChannel.receive() if (response.size >= 3 && response[0] == BUTTONLESS_RESPONSE_CODE && response[2] != 0x01.toByte()) { Logger.w { "DFU: Buttonless DFU response indicates error: ${response.toHexString()}" } @@ -152,7 +149,7 @@ class SecureDfuTransport( * Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling * notifications on the Control Point. */ - suspend fun connectToDfuMode(): Result = safeCatching { + suspend fun connectToDfuMode(): Result = runCatching { val dfuAddress = calculateMacPlusOne(address) val targetAddresses = setOf(address, dfuAddress) Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." } @@ -165,7 +162,7 @@ class SecureDfuTransport( bleConnection.connectionState.onEach { Logger.d { "DFU: Connection state → $it" } }.launchIn(transportScope) - val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT) + val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT_MS) if (connected is BleConnectionState.Disconnected) { throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address}") } @@ -191,7 +188,7 @@ class SecureDfuTransport( } .launchIn(this) - delay(SUBSCRIPTION_SETTLE) + delay(SUBSCRIPTION_SETTLE_MS) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -210,7 +207,7 @@ class SecureDfuTransport( * PRN is explicitly disabled (set to 0) for the init packet per the Nordic DFU library convention — the init packet * is small (<512 bytes, fits in a single object) and does not benefit from flow control. */ - suspend fun transferInitPacket(initPacket: ByteArray): Result = safeCatching { + suspend fun transferInitPacket(initPacket: ByteArray): Result = runCatching { Logger.i { "DFU: Transferring init packet (${initPacket.size} bytes)..." } setPrn(0) transferObjectWithRetry(DfuObjectType.COMMAND, initPacket, onProgress = null) @@ -231,13 +228,12 @@ class SecureDfuTransport( * @param firmware Raw bytes of the `.bin` file. * @param onProgress Callback receiving progress in [0.0, 1.0]. */ - suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = - safeCatching { - Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } - setPrn(PRN_INTERVAL) - transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) - Logger.i { "DFU: Firmware transferred and executed." } - } + suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result = runCatching { + Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." } + setPrn(PRN_INTERVAL) + transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress) + Logger.i { "DFU: Firmware transferred and executed." } + } // --------------------------------------------------------------------------- // Abort & teardown @@ -251,7 +247,7 @@ class SecureDfuTransport( * accept a fresh DFU session. */ suspend fun abort() { - safeCatching { + runCatching { bleConnection.profile(SecureDfuUuids.SERVICE) { service -> val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) service.write(controlChar, byteArrayOf(DfuOpcode.ABORT), BleWriteType.WITH_RESPONSE) @@ -263,7 +259,7 @@ class SecureDfuTransport( /** Disconnect from the DFU target and cancel the transport coroutine scope. */ suspend fun close() { - safeCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } + runCatching { bleConnection.disconnect() }.onFailure { Logger.w(it) { "DFU: Error during disconnect" } } transportScope.cancel() } @@ -290,7 +286,7 @@ class SecureDfuTransport( } catch (e: Throwable) { lastError = e Logger.w(e) { "DFU: Object transfer failed (attempt ${attempt + 1}/$OBJECT_RETRY_COUNT): ${e.message}" } - if (attempt < OBJECT_RETRY_COUNT - 1) delay(RETRY_DELAY) + if (attempt < OBJECT_RETRY_COUNT - 1) delay(RETRY_DELAY_MS) } } throw lastError ?: DfuException.TransferFailed("Object transfer failed after $OBJECT_RETRY_COUNT attempts") @@ -351,7 +347,7 @@ class SecureDfuTransport( // First-chunk delay: some older bootloaders need time to prepare flash after Create. // The Nordic DFU library uses 400ms for the first chunk. if (isFirstChunk) { - delay(FIRST_CHUNK_DELAY) + delay(FIRST_CHUNK_DELAY_MS) isFirstChunk = false } @@ -403,7 +399,7 @@ class SecureDfuTransport( } catch (e: DfuException.ProtocolError) { if (e.resultCode == DfuResultCode.INVALID_OBJECT && offset + objectSize >= totalBytes) { Logger.w { "DFU: Execute returned INVALID_OBJECT on final object, retrying once..." } - delay(RETRY_DELAY) + delay(RETRY_DELAY_MS) sendExecute() } else { throw e @@ -444,7 +440,7 @@ class SecureDfuTransport( // Wait for the device's PRN receipt notification, then validate CRC. // Skip the wait on the last packet — the final CALCULATE_CHECKSUM covers it. if (prnInterval > 0 && packetsSincePrn >= prnInterval && pos < until) { - val response = awaitNotification(COMMAND_TIMEOUT) + val response = awaitNotification(COMMAND_TIMEOUT_MS) if (response is DfuResponse.ChecksumResult) { val expectedCrc = DfuCrc32.calculate(data, length = pos) if (response.offset != pos || response.crc32 != expectedCrc) { @@ -463,7 +459,7 @@ class SecureDfuTransport( val controlChar = service.characteristic(SecureDfuUuids.CONTROL_POINT) service.write(controlChar, payload, BleWriteType.WITH_RESPONSE) } - return awaitNotification(COMMAND_TIMEOUT) + return awaitNotification(COMMAND_TIMEOUT_MS) } private suspend fun setPrn(value: Int) { @@ -510,13 +506,13 @@ class SecureDfuTransport( Logger.d { "DFU: Object executed." } } - private suspend fun awaitNotification(timeout: Duration): DfuResponse = try { - withTimeout(timeout) { + private suspend fun awaitNotification(timeoutMs: Long): DfuResponse = try { + withTimeout(timeoutMs) { val bytes = notificationChannel.receive() DfuResponse.parse(bytes).also { Logger.d { "DFU: Notification → $it" } } } } catch (_: TimeoutCancellationException) { - throw DfuException.Timeout("No response from Control Point after $timeout") + throw DfuException.Timeout("No response from Control Point after ${timeoutMs}ms") } private fun DfuResponse.requireSuccess(expectedOpcode: Byte) { @@ -545,7 +541,7 @@ class SecureDfuTransport( tag = "DFU", serviceUuid = SecureDfuUuids.SERVICE, retryCount = SCAN_RETRY_COUNT, - retryDelay = SCAN_RETRY_DELAY, + retryDelayMs = SCAN_RETRY_DELAY_MS, predicate = predicate, ) @@ -554,14 +550,14 @@ class SecureDfuTransport( // --------------------------------------------------------------------------- companion object { - private val CONNECT_TIMEOUT = 15.seconds - private val COMMAND_TIMEOUT = 30.seconds - private val SUBSCRIPTION_SETTLE = 500.milliseconds - private val BUTTONLESS_RESPONSE_TIMEOUT = 3.seconds + private const val CONNECT_TIMEOUT_MS = 15_000L + private const val COMMAND_TIMEOUT_MS = 30_000L + private const val SUBSCRIPTION_SETTLE_MS = 500L + private const val BUTTONLESS_RESPONSE_TIMEOUT_MS = 3_000L private const val SCAN_RETRY_COUNT = 3 - private val SCAN_RETRY_DELAY = 2.seconds - private val RETRY_DELAY = 2.seconds - private val FIRST_CHUNK_DELAY = 400.milliseconds + private const val SCAN_RETRY_DELAY_MS = 2_000L + private const val RETRY_DELAY_MS = 2_000L + private const val FIRST_CHUNK_DELAY_MS = 400L /** Response code prefix for Buttonless DFU indications (0x20 = response). */ private const val BUTTONLESS_RESPONSE_CODE: Byte = 0x20 diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt index 0a26fd13e..723fed82f 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt @@ -20,11 +20,9 @@ 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 @@ -61,12 +59,6 @@ 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( @@ -75,7 +67,6 @@ class DefaultFirmwareUpdateManagerTest { radioController = radioController, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, - dispatchers = dispatchers, ) private val usbUpdateHandler = @@ -93,7 +84,6 @@ class DefaultFirmwareUpdateManagerTest { nodeRepository = nodeRepository, bleScanner = bleScanner, bleConnectionFactory = bleConnectionFactory, - dispatchers = dispatchers, ) private fun createManager(address: String?): DefaultFirmwareUpdateManager { diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index 030d84eff..4c48a1ced 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -108,7 +108,6 @@ class FirmwareUpdateIntegrationTest { firmwareUpdateManager, usbManager, fileHandler, - TestApplicationCoroutineScope(testDispatcher), ) @Test diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index a8eddff83..7032ed408 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -124,7 +124,6 @@ class FirmwareUpdateViewModelTest { firmwareUpdateManager, usbManager, fileHandler, - TestApplicationCoroutineScope(testDispatcher), ) @Test diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt index da8f84057..b6a73bc52 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransportTest.kt @@ -614,8 +614,8 @@ class SecureDfuTransportTest { override suspend fun connect(device: BleDevice) = delegate.connect(device) - override suspend fun connectAndAwait(device: BleDevice, timeout: Duration) = - delegate.connectAndAwait(device, timeout) + override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long) = + delegate.connectAndAwait(device, timeoutMs) override suspend fun disconnect() = delegate.disconnect() diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index 23a0d03ab..acb1545bd 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -116,7 +116,6 @@ class FirmwareUpdateViewModelFileTest { firmwareUpdateManager, usbManager, fileHandler, - TestApplicationCoroutineScope(testDispatcher), ) // ----------------------------------------------------------------------- diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts index 5429361f5..242c75bcc 100644 --- a/feature/intro/build.gradle.kts +++ b/feature/intro/build.gradle.kts @@ -38,5 +38,13 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + implementation(libs.kotlinx.coroutines.test) + } + } } } diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index db52c350a..fff9fe21b 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -43,5 +43,13 @@ kotlin { implementation(projects.core.ui) implementation(projects.core.di) } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + implementation(libs.kotlinx.coroutines.test) + } + } } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 294d84e4c..a1a31dbf4 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -17,12 +17,14 @@ package org.meshtastic.feature.map import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds @@ -39,7 +41,6 @@ import org.meshtastic.core.resources.eight_hours import org.meshtastic.core.resources.one_day import org.meshtastic.core.resources.one_hour import org.meshtastic.core.resources.two_days -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint @@ -146,8 +147,7 @@ open class BaseMapViewModel( fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) - fun deleteWaypoint(id: Int) = - safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) } + fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) } fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) @@ -159,7 +159,7 @@ open class BaseMapViewModel( } private fun sendDataPacket(p: DataPacket) { - safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) } + viewModelScope.launch(ioDispatcher) { radioController.sendMessage(p) } } fun generatePacketId(): Int = radioController.getPacketId() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index 8d2af9c4d..2c0b5e7b8 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -26,8 +26,8 @@ fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current mapScreen( - { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip - { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails + { backStack.add(NodesRoute.NodeDetail(it)) }, // onClickNodeChip + { backStack.add(NodesRoute.NodeDetail(it)) }, // navigateToNodeDetails args.waypointId, ) } diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts index f2887d98a..e06b417b7 100644 --- a/feature/messaging/build.gradle.kts +++ b/feature/messaging/build.gradle.kts @@ -24,6 +24,7 @@ kotlin { android { namespace = "org.meshtastic.feature.messaging" androidResources.enable = false + withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -42,6 +43,7 @@ kotlin { implementation(projects.core.ui) implementation(libs.jetbrains.navigation3.ui) + implementation(libs.jetbrains.navigationevent.compose) implementation(libs.androidx.paging.common) implementation(libs.androidx.paging.compose) @@ -54,8 +56,6 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) } - commonTest.dependencies { implementation(libs.compose.multiplatform.ui.test) } - - jvmTest.dependencies { implementation(compose.desktop.currentOs) } + val androidHostTest by getting { dependencies { implementation(libs.androidx.work.testing) } } } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt similarity index 100% rename from feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt rename to feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/QuickChatPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt similarity index 100% rename from feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt rename to feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt similarity index 100% rename from feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt rename to feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/component/ReactionPreviews.kt diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt similarity index 81% rename from feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt rename to feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index cf45cb1ec..30f65afff 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -16,21 +16,25 @@ */ package org.meshtastic.feature.messaging.component -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.tooling.preview.NodePreviewParameterProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider -import kotlin.test.Test -@OptIn(ExperimentalTestApi::class) +@RunWith(AndroidJUnit4::class) class MessageItemTest { + @get:Rule val composeTestRule = createComposeRule() + @Test - fun mqttIconIsDisplayedWhenViaMqttIsTrue() = runComposeUiTest { + fun mqttIconIsDisplayedWhenViaMqttIsTrue() { val testNode = NodePreviewParameterProvider().minnieMouse val messageWithMqtt = Message( @@ -52,7 +56,7 @@ class MessageItemTest { viaMqtt = true, ) - setContent { + composeTestRule.setContent { MessageItem( message = messageWithMqtt, node = testNode, @@ -65,11 +69,11 @@ class MessageItemTest { } // Check that the MQTT icon is displayed - onNodeWithContentDescription("via MQTT").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("via MQTT").assertIsDisplayed() } @Test - fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() = runComposeUiTest { + fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() { val testNode = NodePreviewParameterProvider().minnieMouse val messageWithoutMqtt = Message( @@ -91,7 +95,7 @@ class MessageItemTest { viaMqtt = false, ) - setContent { + composeTestRule.setContent { MessageItem( message = messageWithoutMqtt, node = testNode, @@ -104,11 +108,11 @@ class MessageItemTest { } // Check that the MQTT icon is not displayed - onNodeWithContentDescription("via MQTT").assertDoesNotExist() + composeTestRule.onNodeWithContentDescription("via MQTT").assertDoesNotExist() } @Test - fun messageItem_hasCorrectSemanticContentDescription() = runComposeUiTest { + fun messageItem_hasCorrectSemanticContentDescription() { val testNode = NodePreviewParameterProvider().minnieMouse val message = Message( @@ -130,7 +134,7 @@ class MessageItemTest { viaMqtt = false, ) - setContent { + composeTestRule.setContent { MessageItem( message = message, node = testNode, @@ -143,6 +147,8 @@ class MessageItemTest { } // Verify that the node containing the message text exists and matches the text - onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World").assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World") + .assertIsDisplayed() } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt index 8cc621e1c..8d9236a8a 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -65,7 +65,6 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer import org.meshtastic.core.database.entity.QuickChatAction -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.getChannel @@ -328,7 +327,7 @@ fun MessageScreen( Column { AnimatedVisibility(visible = showQuickChat) { QuickChatRow( - enabled = connectionState is ConnectionState.Connected, + enabled = connectionState.isConnected(), actions = quickChatActions, onClick = { action -> handleQuickChatAction( @@ -345,7 +344,7 @@ fun MessageScreen( ourNode = ourNode, ) MessageInput( - isEnabled = connectionState is ConnectionState.Connected, + isEnabled = connectionState.isConnected(), isHomoglyphEncodingEnabled = homoglyphEncodingEnabled, textFieldState = messageInputState, onSendMessage = { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index 9a742a4ea..9cd435f82 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -43,7 +44,8 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType @@ -450,12 +452,23 @@ private fun UpdateUnreadCountPaged( onUnreadChange: (Long, Long) -> Unit, ) { val currentOnUnreadChange by rememberUpdatedState(onUnreadChange) - var isResumed by remember { mutableStateOf(false) } + val lifecycleOwner = LocalLifecycleOwner.current + var isResumed by remember { + mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) + } // Track lifecycle state changes - LifecycleResumeEffect(Unit) { - isResumed = true - onPauseOrDispose { isResumed = false } + DisposableEffect(lifecycleOwner) { + val observer = + androidx.lifecycle.LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> isResumed = true + Lifecycle.Event.ON_PAUSE -> isResumed = false + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } // Track remote message count to restart effect when remote messages change diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 4d3e5679d..7c57b46af 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ContactSettings @@ -48,7 +49,6 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.usecase.SendMessageUseCase -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet @@ -157,7 +157,7 @@ class MessageViewModel( } fun setTitle(title: String) { - _title.value = title + viewModelScope.launch { _title.value = title } } fun getMessagesFromPaged(contactKey: String): Flow> { @@ -190,9 +190,7 @@ class MessageViewModel( } fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") { - packetRepository.setContactFilteringDisabled(contactKey, disabled) - } + viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } } fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) @@ -213,21 +211,21 @@ class MessageViewModel( * @param replyId The ID of the message this is a reply to, if any. */ fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) { - safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) } + viewModelScope.launch { sendMessageUseCase.invoke(str, contactKey, replyId) } } - fun sendReaction(emoji: String, replyId: Int, contactKey: String) = safeLaunch(tag = "sendReaction") { + fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) } fun deleteMessages(uuidList: List) = - safeLaunch(context = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) } + viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) } fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) = - safeLaunch(context = ioDispatcher, tag = "clearUnreadCount") { + viewModelScope.launch(ioDispatcher) { val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE if (lastReadTimestamp <= existingTimestamp) { - return@safeLaunch + return@launch } packetRepository.clearUnreadCount(contact, lastReadTimestamp) packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt index 6451b8885..53d023d08 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt @@ -17,11 +17,12 @@ package org.meshtastic.feature.messaging import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.repository.QuickChatActionRepository -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed @KoinViewModel @@ -30,7 +31,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) fun updateActionPositions(actions: List) { - safeLaunch(context = ioDispatcher, tag = "updateActionPositions") { + viewModelScope.launch(ioDispatcher) { for (position in actions.indices) { quickChatActionRepository.setItemPosition(actions[position].uuid, position) } @@ -38,8 +39,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR } fun addQuickChatAction(action: QuickChatAction) = - safeLaunch(context = ioDispatcher, tag = "addQuickChatAction") { quickChatActionRepository.upsert(action) } + viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) } fun deleteQuickChatAction(action: QuickChatAction) = - safeLaunch(context = ioDispatcher, tag = "deleteQuickChatAction") { quickChatActionRepository.delete(action) } + viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index 5ffb5ea1d..380b913a5 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -36,18 +36,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.action_copy_message -import org.meshtastic.core.resources.action_delete_message -import org.meshtastic.core.resources.action_react_with_emoji -import org.meshtastic.core.resources.action_select_message -import org.meshtastic.core.resources.action_send_reply -import org.meshtastic.core.resources.action_show_message_status import org.meshtastic.core.resources.copy import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.device_metrics_label_value @@ -62,7 +56,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Reply import org.meshtastic.core.ui.icon.SelectAll -@Suppress("LongMethod") @Composable fun MessageActionsContent( quickEmojis: List, @@ -91,35 +84,20 @@ fun MessageActionsContent( Text(stringResource(Res.string.device_metrics_label_value, title, statusText.orEmpty())) }, leadingContent = { MessageStatusIcon(status = status) }, - modifier = - Modifier.clickable( - onClickLabel = stringResource(Res.string.action_show_message_status), - role = Role.Button, - onClick = onStatus, - ), + modifier = Modifier.clickable(onClick = onStatus), ) } ListItem( headlineContent = { Text(stringResource(Res.string.reply)) }, leadingContent = { Icon(MeshtasticIcons.Reply, contentDescription = stringResource(Res.string.reply)) }, - modifier = - Modifier.clickable( - onClickLabel = stringResource(Res.string.action_send_reply), - role = Role.Button, - onClick = onReply, - ), + modifier = Modifier.clickable(onClick = onReply), ) ListItem( headlineContent = { Text(stringResource(Res.string.copy)) }, leadingContent = { Icon(MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy)) }, - modifier = - Modifier.clickable( - onClickLabel = stringResource(Res.string.action_copy_message), - role = Role.Button, - onClick = onCopy, - ), + modifier = Modifier.clickable(onClick = onCopy), ) ListItem( @@ -127,23 +105,13 @@ fun MessageActionsContent( leadingContent = { Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select)) }, - modifier = - Modifier.clickable( - onClickLabel = stringResource(Res.string.action_select_message), - role = Role.Button, - onClick = onSelect, - ), + modifier = Modifier.clickable(onClick = onSelect), ) ListItem( headlineContent = { Text(stringResource(Res.string.delete)) }, leadingContent = { Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete)) }, - modifier = - Modifier.clickable( - onClickLabel = stringResource(Res.string.action_delete_message), - role = Role.Button, - onClick = onDelete, - ), + modifier = Modifier.clickable(onClick = onDelete), ) } } @@ -163,15 +131,10 @@ private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit, Modifier.size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable( - onClickLabel = stringResource(Res.string.action_react_with_emoji), - role = Role.Button, - ) { - onReact(emoji) - }, + .clickable { onReact(emoji) }, contentAlignment = Alignment.Center, ) { - Text(text = emoji, style = MaterialTheme.typography.titleMedium) + Text(text = emoji, fontSize = 20.sp) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 7d8747eb8..586b91dd6 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -29,12 +29,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -45,11 +47,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -73,8 +72,6 @@ import org.meshtastic.core.ui.emoji.EmojiPickerDialog import org.meshtastic.core.ui.icon.FormatQuote import org.meshtastic.core.ui.icon.HopCount import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.theme.ContrastLevel -import org.meshtastic.core.ui.theme.LocalContrastLevel import org.meshtastic.core.ui.theme.MessageItemColors import org.meshtastic.core.ui.util.createClipEntry @@ -178,9 +175,7 @@ fun MessageItem( } val containsBel = message.text.contains('\u0007') - val contrastLevel = LocalContrastLevel.current - val nodeColor = Color(if (message.fromLocal) ourNode.colors.second else node.colors.second) val alpha = if (message.filtered) { FILTERED_ALPHA @@ -189,31 +184,15 @@ fun MessageItem( } else { NORMAL_ALPHA } - val containerColor = - when (contrastLevel) { - ContrastLevel.HIGH -> - when { - message.filtered -> MaterialTheme.colorScheme.surfaceContainerLow - inSelectionMode && selected -> MaterialTheme.colorScheme.surfaceContainerHighest - inSelectionMode && !selected -> MaterialTheme.colorScheme.surfaceContainerLow - else -> MaterialTheme.colorScheme.surfaceContainerHigh - } - ContrastLevel.MEDIUM -> nodeColor.copy(alpha = (alpha + 0.2f).coerceAtMost(1f)) - ContrastLevel.STANDARD -> nodeColor.copy(alpha = alpha) - } - val contentColor = - when (contrastLevel) { - ContrastLevel.HIGH, - ContrastLevel.MEDIUM, - -> MaterialTheme.colorScheme.onSurface - ContrastLevel.STANDARD -> Color(if (message.fromLocal) ourNode.colors.first else node.colors.first) - } - val metadataStyle = - when (contrastLevel) { - ContrastLevel.HIGH -> MaterialTheme.typography.bodySmall - else -> MaterialTheme.typography.labelSmall + if (message.fromLocal) { + Color(ourNode.colors.second).copy(alpha = alpha) + } else { + Color(node.colors.second).copy(alpha = alpha) } + val cardColors = + CardDefaults.cardColors() + .copy(containerColor = containerColor, contentColor = contentColorFor(containerColor)) val messageShape = getMessageBubbleShape( cornerRadius = 8.dp, @@ -227,12 +206,7 @@ fun MessageItem( if (containsBel) { Modifier.border(2.dp, color = MessageItemColors.Red, shape = messageShape) } else { - when (contrastLevel) { - ContrastLevel.HIGH -> Modifier.border(2.dp, color = nodeColor, shape = messageShape) - ContrastLevel.MEDIUM -> - Modifier.border(1.dp, color = nodeColor.copy(alpha = 0.6f), shape = messageShape) - ContrastLevel.STANDARD -> Modifier - } + Modifier }, ) val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name @@ -270,12 +244,9 @@ fun MessageItem( onDoubleClick = onDoubleClick, ) .then(messageModifier) - .semantics(mergeDescendants = true) { - contentDescription = messageA11yText - role = Role.Button - }, + .semantics(mergeDescendants = true) { contentDescription = messageA11yText }, color = containerColor, - contentColor = contentColor, + contentColor = contentColorFor(containerColor), shape = messageShape, ) { Column(modifier = Modifier.width(IntrinsicSize.Max)) { @@ -283,11 +254,16 @@ fun MessageItem( modifier = Modifier.fillMaxWidth(), message = message, ourNode = ourNode, + hasSamePrev = hasSamePrev, onNavigateToOriginalMessage = onNavigateToOriginalMessage, ) Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { - AutoLinkText(text = message.text, style = MaterialTheme.typography.bodyMedium, color = contentColor) + AutoLinkText( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + color = cardColors.contentColor, + ) Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { if (!message.fromLocal) { @@ -305,10 +281,7 @@ fun MessageItem( imageVector = MeshtasticIcons.HopCount, contentDescription = null, modifier = Modifier.size(14.dp), - tint = - contentColor.copy( - alpha = if (contrastLevel == ContrastLevel.HIGH) 1f else 0.7f, - ), + tint = cardColors.contentColor.copy(alpha = 0.7f), ) Text( text = @@ -317,7 +290,7 @@ fun MessageItem( } else { "?" }, - style = metadataStyle, + style = MaterialTheme.typography.labelSmall, ) } } @@ -333,13 +306,8 @@ fun MessageItem( if (message.filtered) { Text( text = stringResource(Res.string.filter_message_label), - style = metadataStyle, - color = - if (contrastLevel == ContrastLevel.HIGH) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp, end = 4.dp), ) } @@ -350,7 +318,11 @@ fun MessageItem( ) } Spacer(modifier = Modifier.weight(1f)) - Text(modifier = Modifier.padding(start = 16.dp), text = message.time, style = metadataStyle) + Text( + modifier = Modifier.padding(start = 16.dp), + text = message.time, + style = MaterialTheme.typography.labelSmall, + ) } } } @@ -384,33 +356,30 @@ private enum class ActiveSheet { private fun OriginalMessageSnippet( message: Message, ourNode: Node, + hasSamePrev: Boolean, onNavigateToOriginalMessage: (Int) -> Unit, modifier: Modifier = Modifier, ) { val originalMessage = message.originalMessage if (originalMessage != null && originalMessage.packetId != 0) { val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node - val contrastLevel = LocalContrastLevel.current - val replyContainerColor = - when (contrastLevel) { - ContrastLevel.HIGH -> MaterialTheme.colorScheme.surfaceContainer - else -> Color(originalMessageNode.colors.second).copy(alpha = 0.8f) - } - val replyContentColor = - when (contrastLevel) { - ContrastLevel.HIGH, - ContrastLevel.MEDIUM, - -> MaterialTheme.colorScheme.onSurface - ContrastLevel.STANDARD -> Color(originalMessageNode.colors.first) - } - // Rectangle shape — the outer message bubble's Surface clips to its - // rounded corners, so the reply header inherits the correct top radii - // automatically and stays square on the bottom where body text follows. + val cardColors = + CardDefaults.cardColors() + .copy( + containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.8f), + contentColor = Color(originalMessageNode.colors.first), + ) Surface( modifier = modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) }, - contentColor = replyContentColor, - color = replyContainerColor, - shape = RectangleShape, + contentColor = cardColors.contentColor, + color = cardColors.containerColor, + shape = + getMessageBubbleShape( + cornerRadius = 16.dp, + isSender = originalMessage.fromLocal, + hasSamePrev = hasSamePrev, + hasSameNext = true, // always square off original message bottom + ), ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt index 7b361d497..501a3f7dc 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt @@ -24,13 +24,11 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.message_delivery_status import org.meshtastic.core.ui.icon.Acknowledged -import org.meshtastic.core.ui.icon.AddLink -import org.meshtastic.core.ui.icon.CloudUpload -import org.meshtastic.core.ui.icon.LinkIcon import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MessageEnroute import org.meshtastic.core.ui.icon.MessageError import org.meshtastic.core.ui.icon.MqttDelivered +import org.meshtastic.core.ui.icon.MqttSyncing import org.meshtastic.core.ui.icon.Warning @Composable @@ -38,10 +36,10 @@ fun MessageStatusIcon(status: MessageStatus, modifier: Modifier = Modifier) { val icon = when (status) { MessageStatus.RECEIVED -> MeshtasticIcons.Acknowledged - MessageStatus.QUEUED -> MeshtasticIcons.CloudUpload + MessageStatus.QUEUED -> MeshtasticIcons.MqttSyncing MessageStatus.DELIVERED -> MeshtasticIcons.MqttDelivered - MessageStatus.SFPP_ROUTING -> MeshtasticIcons.AddLink - MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.LinkIcon + MessageStatus.SFPP_ROUTING -> MeshtasticIcons.MqttSyncing + MessageStatus.SFPP_CONFIRMED -> MeshtasticIcons.MqttDelivered MessageStatus.ENROUTE -> MeshtasticIcons.MessageEnroute MessageStatus.ERROR -> MeshtasticIcons.MessageError else -> MeshtasticIcons.Warning diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 9b8267793..6545083bb 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -123,6 +123,7 @@ internal fun ReactionItem( text = emojiCount.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, + fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -143,7 +144,7 @@ internal fun ReactionRow( AnimatedVisibility(emojiGroups.isNotEmpty(), modifier = modifier) { LazyRow(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - items(emojiGroups.entries.toList(), key = { it.key }) { entry -> + items(emojiGroups.entries.toList()) { entry -> val emoji = entry.key val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } @@ -237,7 +238,7 @@ internal fun ReactionDialog( } LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - items(groupedEmojis.entries.toList(), key = { it.key }) { entry -> + items(groupedEmojis.entries.toList()) { entry -> val emoji = entry.key val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } @@ -247,13 +248,7 @@ internal fun ReactionDialog( text = "$emoji${reactions.size}", modifier = Modifier.clip(CircleShape) - .background( - if (selectedEmoji == emoji) { - MaterialTheme.colorScheme.surfaceContainerHigh - } else { - Color.Transparent - }, - ) + .background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent) .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier) .padding(8.dp) .clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji }, @@ -265,7 +260,7 @@ internal fun ReactionDialog( HorizontalDivider(Modifier.padding(vertical = 8.dp)) LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(filteredReactions, key = { reaction -> "${reaction.user.id}:${reaction.emoji}" }) { reaction -> + items(filteredReactions) { reaction -> Column(modifier = Modifier.padding(horizontal = 8.dp)) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt index 62b57d3a8..0f347f980 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -62,10 +61,9 @@ fun EntryProviderScope.contactsGraph( contactKey = contactKey, message = args.message, viewModel = messageViewModel, - navigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, - navigateToQuickChatOptions = - dropUnlessResumed { backStack.add(org.meshtastic.core.navigation.ContactsRoute.QuickChat) }, - onNavigateBack = dropUnlessResumed { backStack.removeLastOrNull() }, + navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, + navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoute.QuickChat) }, + onNavigateBack = { backStack.removeLastOrNull() }, ) } @@ -75,13 +73,13 @@ fun EntryProviderScope.contactsGraph( ShareScreen( viewModel = viewModel, onConfirm = { contactKey -> backStack.replaceLast(ContactsRoute.Messages(contactKey, message)) }, - onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onNavigateUp = { backStack.removeLastOrNull() }, ) } entry(metadata = { ListDetailSceneStrategy.extraPane() }) { val viewModel = koinViewModel() - QuickChatScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt index 1607ffa5d..d8f7eeae0 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.flow.Flow -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.navigation.ChannelsRoute import org.meshtastic.core.navigation.ContactsRoute import org.meshtastic.core.navigation.NodesRoute @@ -35,7 +35,7 @@ fun AdaptiveContactsScreen( scrollToTopEvents: Flow, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, - onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, + onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, ) { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index 7abaf6db6..e522ba0e2 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -44,7 +44,6 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -62,10 +61,9 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Contact import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.util.TimeConstants @@ -118,7 +116,7 @@ fun ContactsScreen( onNavigateToShare: () -> Unit, sharedContactRequested: SharedContact?, requestChannelSet: ChannelSet?, - onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit, + onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit, onClearSharedContactRequested: () -> Unit, onClearRequestChannelUrl: () -> Unit, viewModel: ContactsViewModel, @@ -132,8 +130,8 @@ fun ContactsScreen( val scope = rememberCoroutineScope() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - var showMuteDialog by rememberSaveable { mutableStateOf(false) } - var showDeleteDialog by rememberSaveable { mutableStateOf(false) } + var showMuteDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } // State for managing selected contacts val selectedContactKeys = remember { mutableStateListOf() } @@ -234,7 +232,7 @@ fun ContactsScreen( MainAppBar( title = stringResource(Res.string.conversations), ourNode = ourNode, - showNodeChip = ourNode != null && connectionState is ConnectionState.Connected, + showNodeChip = ourNode != null && connectionState.isConnected(), canNavigateUp = false, onNavigateUp = {}, actions = { @@ -252,11 +250,11 @@ fun ContactsScreen( ) }, floatingActionButton = { - if (connectionState is ConnectionState.Connected) { + if (connectionState.isConnected()) { MeshtasticImportFAB( sharedContact = sharedContactRequested, onImport = { uriString -> - onHandleDeepLink(CommonUri.parse(uriString)) { + onHandleDeepLink(MeshtasticUri(uriString)) { scope.launch { showToast(Res.string.channel_invalid) } } }, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index f8aa46032..865242cfb 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Contact @@ -36,7 +37,6 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet import kotlin.collections.map as collectionsMap @@ -188,20 +188,17 @@ class ContactsViewModel( fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) fun deleteContacts(contacts: List) = - safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) } + viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) } - fun markAllAsRead() = - safeLaunch(context = ioDispatcher, tag = "markAllAsRead") { packetRepository.clearAllUnreadCounts() } + fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() } fun setMuteUntil(contacts: List, until: Long) = - safeLaunch(context = ioDispatcher, tag = "setMuteUntil") { packetRepository.setMuteUntil(contacts, until) } + viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) } fun getContactSettings() = packetRepository.getContactSettings() fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { - safeLaunch(context = ioDispatcher, tag = "setContactFilteringDisabled") { - packetRepository.setContactFilteringDisabled(contactKey, disabled) - } + viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } } /** diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 0d89b55f6..6195fb13b 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -62,5 +62,14 @@ kotlin { } androidMain.dependencies { implementation(libs.markdown.renderer.android) } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.androidx.test.ext.junit) + } + } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index 699021fbc..b7c5f35bd 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.node.compass import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.formatString @@ -35,7 +37,6 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.ui.component.precisionBitsToMeters -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.proto.Config import org.meshtastic.proto.Position import kotlin.math.abs @@ -91,17 +92,13 @@ class CompassViewModel( updatesJob?.cancel() - updatesJob = - safeLaunch(tag = "compassUpdates") { - combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { - heading, - location, - -> - buildState(heading, location) - } - .flowOn(dispatchers.default) - .collect { _uiState.value = it } + updatesJob = viewModelScope.launch { + combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) { heading, location -> + buildState(heading, location) } + .flowOn(dispatchers.default) + .collect { _uiState.value = it } + } } fun stop() { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt index 067d9cf40..aa44a6b7e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt @@ -40,7 +40,6 @@ import org.meshtastic.core.resources.ic_radioactive import org.meshtastic.core.resources.ic_soil_moisture import org.meshtastic.core.resources.ic_soil_temperature import org.meshtastic.core.resources.lux -import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.pressure import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.soil_moisture @@ -223,18 +222,6 @@ internal fun EnvironmentMetrics( ), ) } - // 1-Wire temperature sensors (up to 8 channels) - one_wire_temperature - .filterNot { it.isNaN() } - .forEachIndexed { idx, temp -> - add( - DrawableMetricInfo( - label = Res.string.one_wire_temperature, - value = "${idx + 1}: ${temp.toTempString(isFahrenheit)}", - icon = Res.drawable.ic_soil_temperature, - ), - ) - } } } FlowRow( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 036fd3404..51f131bda 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.Base64Factory -import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.formatUptime @@ -263,7 +263,7 @@ private fun SignalRow(node: Node) { if (node.snr != Float.MAX_VALUE) { InfoItem( label = stringResource(Res.string.snr), - value = MetricFormatter.snr(node.snr), + value = formatString("%.1f dB", node.snr), icon = MeshtasticIcons.Snr, modifier = Modifier.weight(1f), ) @@ -273,7 +273,7 @@ private fun SignalRow(node: Node) { if (node.rssi != Int.MAX_VALUE) { InfoItem( label = stringResource(Res.string.rssi), - value = MetricFormatter.rssi(node.rssi), + value = formatString("%d dBm", node.rssi), icon = MeshtasticIcons.Rssi, modifier = Modifier.weight(1f), ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt index 0bc022c34..cfac18158 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt @@ -49,7 +49,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign @@ -57,7 +56,6 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.clear import org.meshtastic.core.resources.desc_node_filter_clear import org.meshtastic.core.resources.node_filter_exclude_infrastructure import org.meshtastic.core.resources.node_filter_exclude_mqtt @@ -180,19 +178,14 @@ private fun NodeFilterTextField(filterText: String, onTextChange: (String) -> Un onValueChange = onTextChange, trailingIcon = { if (filterText.isNotEmpty() || isFocused) { - val clearLabel = stringResource(Res.string.clear) Icon( MeshtasticIcons.Close, contentDescription = stringResource(Res.string.desc_node_filter_clear), modifier = - Modifier.clickable( - onClickLabel = clearLabel, - role = Role.Button, - onClick = { - onTextChange("") - focusManager.clearFocus() - }, - ), + Modifier.clickable { + onTextChange("") + focusManager.clearFocus() + }, ) } }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 22f4422ad..cbf99e9ca 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -46,11 +47,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.isUnmessageableRole +import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_utilization @@ -95,6 +96,7 @@ private const val ACTIVE_ALPHA = 0.5f private const val INACTIVE_ALPHA = 0.2f private const val GRID_COLUMNS = 3 +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @Suppress("LongMethod") fun NodeItem( @@ -106,7 +108,6 @@ fun NodeItem( onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, connectionState: ConnectionState, - deviceType: DeviceType? = null, isActive: Boolean = false, ) { val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } @@ -167,7 +168,6 @@ fun NodeItem( isMuted = isMuted, isUnmessageable = unmessageable, connectionState = connectionState, - deviceType = deviceType, contentColor = contentColor, ) @@ -259,14 +259,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col icon = MeshtasticIcons.ChannelUtilization, contentDescription = stringResource(Res.string.channel_utilization), label = stringResource(Res.string.channel_utilization), - text = MetricFormatter.percent(thatNode.deviceMetrics.channel_utilization ?: 0f), + text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization), contentColor = contentColor, ) IconInfo( icon = MeshtasticIcons.AirUtilization, contentDescription = stringResource(Res.string.air_utilization), label = stringResource(Res.string.air_utilization), - text = MetricFormatter.percent(thatNode.deviceMetrics.air_util_tx ?: 0f), + text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx), contentColor = contentColor, ) } @@ -319,24 +319,31 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C } if ((env.temperature ?: 0f) != 0f) { - val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit) + val temp = + if (tempInFahrenheit) { + formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f)) + } else { + formatString("%.1f°C", env.temperature ?: 0f) + } items.add { TemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.relative_humidity ?: 0f) != 0f) { items.add { - HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor) + HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor) } } if ((env.barometric_pressure ?: 0f) != 0f) { items.add { - PressureInfo( - pressure = MetricFormatter.pressure(env.barometric_pressure ?: 0f), - contentColor = contentColor, - ) + PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor) } } if ((env.soil_temperature ?: 0f) != 0f) { - val temp = MetricFormatter.temperature(env.soil_temperature ?: 0f, tempInFahrenheit) + val temp = + if (tempInFahrenheit) { + formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f)) + } else { + formatString("%.1f°C", env.soil_temperature ?: 0f) + } items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) } } if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) { @@ -345,7 +352,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.voltage ?: 0f) != 0f) { items.add { PowerInfo( - value = MetricFormatter.voltage(env.voltage ?: 0f), + value = formatString("%.2fV", env.voltage ?: 0f), label = stringResource(Res.string.voltage), contentColor = contentColor, ) @@ -354,7 +361,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C if ((env.current ?: 0f) != 0f) { items.add { PowerInfo( - value = MetricFormatter.current(env.current ?: 0f), + value = formatString("%.1fmA", env.current ?: 0f), label = stringResource(Res.string.current), contentColor = contentColor, ) @@ -384,6 +391,7 @@ private fun MetricsGrid(items: List<@Composable () -> Unit>) { } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun NodeItemHeader( thatNode: Node, @@ -395,7 +403,6 @@ private fun NodeItemHeader( isMuted: Boolean, isUnmessageable: Boolean, connectionState: ConnectionState, - deviceType: DeviceType?, contentColor: Color, ) { Row( @@ -441,7 +448,6 @@ private fun NodeItemHeader( isMuted = isMuted, isUnmessageable = isUnmessageable, connectionState = connectionState, - deviceType = deviceType, contentColor = contentColor, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt index 1bbafad6a..007c12c96 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DeviceType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connecting @@ -47,11 +46,17 @@ import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.mute_always import org.meshtastic.core.resources.unmessageable import org.meshtastic.core.resources.unmonitored_or_infrastructure -import org.meshtastic.core.ui.component.ConnectionsNavIcon +import org.meshtastic.core.ui.icon.DeviceSleep +import org.meshtastic.core.ui.icon.Disconnected import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.MqttDelivered +import org.meshtastic.core.ui.icon.MqttSyncing import org.meshtastic.core.ui.icon.Unmessageable import org.meshtastic.core.ui.icon.VolumeOff +import org.meshtastic.core.ui.theme.StatusColors.StatusGreen +import org.meshtastic.core.ui.theme.StatusColors.StatusOrange +import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @OptIn(ExperimentalMaterial3Api::class) @@ -63,12 +68,11 @@ fun NodeStatusIcons( isMuted: Boolean, connectionState: ConnectionState, modifier: Modifier = Modifier, - deviceType: DeviceType? = null, contentColor: Color = LocalContentColor.current, ) { Row(modifier = modifier.padding(4.dp)) { if (isThisNode) { - ThisNodeStatusBadge(connectionState = connectionState, deviceType = deviceType) + ThisNodeStatusBadge(connectionState) } if (isUnmessageable) { @@ -100,7 +104,7 @@ fun NodeStatusIcons( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: DeviceType?) { +private fun ThisNodeStatusBadge(connectionState: ConnectionState) { TooltipBox( positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { @@ -119,10 +123,55 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: De }, state = rememberTooltipState(), ) { - ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType, modifier = Modifier.size(24.dp)) + when (connectionState) { + ConnectionState.Connected -> ConnectedStatusIcon() + ConnectionState.Connecting -> ConnectingStatusIcon() + ConnectionState.Disconnected -> DisconnectedStatusIcon() + ConnectionState.DeviceSleep -> DeviceSleepStatusIcon() + } } } +@Composable +private fun ConnectedStatusIcon() { + Icon( + imageVector = MeshtasticIcons.MqttDelivered, + contentDescription = stringResource(Res.string.connected), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.StatusGreen, + ) +} + +@Composable +private fun ConnectingStatusIcon() { + Icon( + imageVector = MeshtasticIcons.MqttSyncing, + contentDescription = stringResource(Res.string.connecting), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.StatusOrange, + ) +} + +@Composable +private fun DisconnectedStatusIcon() { + Icon( + imageVector = MeshtasticIcons.Disconnected, + contentDescription = stringResource(Res.string.disconnected), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.StatusRed, + ) +} + +@Composable +private fun DeviceSleepStatusIcon() { + Icon( + imageVector = MeshtasticIcons.DeviceSleep, + contentDescription = stringResource(Res.string.device_sleeping), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.StatusYellow, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StatusBadge( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index f3a71b374..22588aebd 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -137,6 +137,14 @@ private fun rememberTelemetricFeatures( requestAction = { NodeMenuAction.RequestUserInfo(it) }, isVisible = { !isLocal }, ), + TelemetricFeature( + titleRes = LogsType.POSITIONS.titleRes, + icon = LogsType.POSITIONS.icon, + requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) }, + logsType = LogsType.POSITIONS, + content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) }, + hasContent = { it.latitude != 0.0 || it.longitude != 0.0 }, + ), TelemetricFeature( titleRes = LogsType.TRACEROUTE.titleRes, icon = LogsType.TRACEROUTE.icon, @@ -200,14 +208,6 @@ private fun rememberTelemetricFeatures( requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) }, logsType = LogsType.PAX, ), - TelemetricFeature( - titleRes = LogsType.POSITIONS.titleRes, - icon = LogsType.POSITIONS.icon, - requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) }, - logsType = LogsType.POSITIONS, - content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) }, - hasContent = { it.latitude != 0.0 || it.longitude != 0.0 }, - ), ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt index 559582417..9ce025604 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt @@ -43,7 +43,10 @@ internal fun handleNodeAction( val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode) navigateToMessages(route) } - is NodeMenuAction.Remove -> viewModel.handleNodeMenuAction(menuAction, onNavigateUp) + is NodeMenuAction.Remove -> { + viewModel.handleNodeMenuAction(menuAction) + onNavigateUp() + } else -> viewModel.handleNodeMenuAction(menuAction) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index e891d8ae0..45b3cc2b8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -21,11 +21,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.DataPacket @@ -33,7 +35,6 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.UiText -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.metrics.EnvironmentMetricsState @@ -80,7 +81,7 @@ class NodeDetailViewModel( if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState()) getNodeDetailsUseCase(nodeId) } - .stateInWhileSubscribed(initialValue = NodeDetailUiState()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState()) fun start(nodeId: Int) { if (manualNodeId.value != nodeId) { @@ -89,10 +90,9 @@ class NodeDetailViewModel( } /** Dispatches high-level node management actions like removal, muting, or favoriting. */ - fun handleNodeMenuAction(action: NodeMenuAction, onAfterRemove: () -> Unit = {}) { + fun handleNodeMenuAction(action: NodeMenuAction) { when (action) { - is NodeMenuAction.Remove -> - nodeManagementActions.requestRemoveNode(viewModelScope, action.node, onAfterRemove) + is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node) is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node) is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node) is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index 9c021e666..436954201 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -50,14 +50,11 @@ constructor( private val radioController: RadioController, private val alertManager: AlertManager, ) { - open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) { + open fun requestRemoveNode(scope: CoroutineScope, node: Node) { alertManager.showAlert( titleRes = Res.string.remove, messageRes = Res.string.remove_node_text, - onConfirm = { - removeNode(scope, node.num) - onAfterRemove() - }, + onConfirm = { removeNode(scope, node.num) }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 2e8093ad8..9c2c208f4 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -72,7 +72,7 @@ fun NodeListScreen( onNavigateToChannels: () -> Unit = {}, scrollToTopEvents: Flow? = null, activeNodeId: Int? = null, - onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val showToast = org.meshtastic.core.ui.util.rememberShowToastResource() val scope = rememberCoroutineScope() @@ -97,7 +97,6 @@ fun NodeListScreen( } val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() - val deviceType by viewModel.deviceType.collectAsStateWithLifecycle() val isScrollInProgress by remember { derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) } @@ -125,7 +124,7 @@ fun NodeListScreen( alignment = androidx.compose.ui.Alignment.BottomEnd, ), onImport = { uriString -> - onHandleDeepLink(org.meshtastic.core.common.util.CommonUri.parse(uriString)) { + onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) { scope.launch { showToast(Res.string.channel_invalid) } } }, @@ -188,7 +187,6 @@ fun NodeListScreen( onClick = { navigateToNodeDetails(node.num) }, onLongClick = longClick, connectionState = connectionState, - deviceType = deviceType, isActive = isActive, ) val isThisNode = remember(node) { ourNode?.num == node.num } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index 172a296eb..df65a3477 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -23,16 +23,13 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeManagementActions @@ -48,7 +45,6 @@ class NodeListViewModel( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val radioController: RadioController, - private val radioInterfaceService: RadioInterfaceService, val nodeManagementActions: NodeManagementActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, val nodeFilterPreferences: NodeFilterPreferences, @@ -62,11 +58,6 @@ class NodeListViewModel( val connectionState = serviceRepository.connectionState - val deviceType: StateFlow = - radioInterfaceService.currentDeviceAddressFlow - .map { address -> address?.let { DeviceType.fromAddress(it) } } - .stateInWhileSubscribed(initialValue = null) - private val nodeSortOption = nodeFilterPreferences.nodeSortOption private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index 88f4d1d6d..0b9f40044 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -31,15 +31,12 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconToggleButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -71,17 +68,12 @@ import org.meshtastic.core.resources.collapse_chart import org.meshtastic.core.resources.expand_chart import org.meshtastic.core.resources.info import org.meshtastic.core.resources.logs -import org.meshtastic.core.resources.save import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.BarChart import org.meshtastic.core.ui.icon.Info import org.meshtastic.core.ui.icon.List import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.icon.Save - -/** Minimum x-step (in seconds) to prevent the default GCD from producing a value of 1 with irregular timestamps. */ -private const val MIN_X_STEP_SECONDS = 60.0 /** * A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point @@ -104,50 +96,38 @@ fun GenericMetricChart( onPointSelected: ((Double) -> Unit)? = null, vicoScrollState: VicoScrollState = rememberVicoScrollState(), ) { - // Key on layer count so Compose rebuilds the entire subtree when legend chip toggles - // add/remove layers. rememberCartesianChart uses vararg internally, so changing the - // argument count without a key corrupts the slot table. - key(layers.size) { - val zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content) + val markerVisibilityListener = + remember(onPointSelected) { + object : CartesianMarkerVisibilityListener { + override fun onShown(marker: CartesianMarker, targets: List) { + targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + } - val markerVisibilityListener = - remember(onPointSelected) { - object : CartesianMarkerVisibilityListener { - override fun onShown(marker: CartesianMarker, targets: List) { - targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } - } - - override fun onUpdated(marker: CartesianMarker, targets: List) { - targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } - } + override fun onUpdated(marker: CartesianMarker, targets: List) { + targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } } } + } - CartesianChartHost( - chart = - @Suppress("SpreadOperator") - rememberCartesianChart( - *layers.toTypedArray(), - startAxis = startAxis, - endAxis = endAxis, - bottomAxis = bottomAxis, - marker = marker, - markerVisibilityListener = markerVisibilityListener, - persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null }, - fadingEdges = rememberFadingEdges(), - decorations = decorations, - // Telemetry timestamps arrive at irregular intervals. Without an explicit - // x-step, Vico computes the GCD of consecutive x-value differences which can - // be as small as 1 second, making the chart logically enormous. A 60-second - // floor keeps the internal slot count reasonable for any practical interval. - getXStep = { model -> maxOf(model.getXDeltaGcd(), MIN_X_STEP_SECONDS) }, - ), - modelProducer = modelProducer, - modifier = modifier, - scrollState = vicoScrollState, - zoomState = zoomState, - ) - } + CartesianChartHost( + chart = + @Suppress("SpreadOperator") + rememberCartesianChart( + *layers.toTypedArray(), + startAxis = startAxis, + endAxis = endAxis, + bottomAxis = bottomAxis, + marker = marker, + markerVisibilityListener = markerVisibilityListener, + persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null }, + fadingEdges = rememberFadingEdges(), + decorations = decorations, + ), + modelProducer = modelProducer, + modifier = modifier, + scrollState = vicoScrollState, + zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content), + ) } /** @@ -159,28 +139,26 @@ fun GenericMetricChart( * * @param isEmpty Whether the chart data is empty — when true, nothing is rendered. * @param legendData Legend items shown below the chart. + * @param key Optional key for the [CartesianChartModelProducer] (e.g. a selected channel). Pass a different value to + * recreate the producer. * @param hiddenSet Indices of hidden legend items (toggleable legend). * @param onToggle Callback when a legend item is toggled; when null, a read-only legend is rendered. * @param content Builder lambda receiving the [CartesianChartModelProducer] and a standard `Modifier.weight(1f)` * suitable for the chart area. - * - * A single [CartesianChartModelProducer] is created per scaffold instance. Vico forbids swapping the producer attached - * to a live [CartesianChartHost] (it throws "A new `CartesianChartModelProducer` was provided…"), so callers must push - * new data through [CartesianChartModelProducer.runTransaction] instead of recreating the producer. Keying the scaffold - * on external state (e.g. a selected channel) caused exactly that crash, so the previous `key` parameter was removed. */ @Composable fun MetricChartScaffold( isEmpty: Boolean, legendData: List, modifier: Modifier = Modifier, + key: Any? = Unit, hiddenSet: Set = emptySet(), onToggle: ((Int) -> Unit)? = null, content: @Composable ColumnScope.(CartesianChartModelProducer, Modifier) -> Unit, ) { Column(modifier = modifier) { if (isEmpty) return@Column - val modelProducer = remember { CartesianChartModelProducer() } + val modelProducer = remember(key) { CartesianChartModelProducer() } val chartModifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp) content(modelProducer, chartModifier) Legend( @@ -234,10 +212,8 @@ fun AdaptiveMetricLayout( * A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and chart-to-list * synchronisation. * - * @param extraActions Additional composable actions rendered in the app bar before the standard buttons (e.g. a + * @param extraActions Additional composable actions rendered in the app bar before the expand/collapse toggle (e.g. a * cooldown traceroute button). - * @param onExportCsv When non-null, a Save [IconButton] is rendered in the app bar that invokes this callback. This - * centralises the CSV export affordance so individual screens only need to provide the export logic. */ @Composable @Suppress("LongMethod") @@ -250,14 +226,13 @@ fun BaseMetricScreen( timeProvider: (T) -> Double, infoData: List = emptyList(), onRequestTelemetry: (() -> Unit)? = null, - onExportCsv: (() -> Unit)? = null, extraActions: @Composable () -> Unit = {}, chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit, listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit, controlPart: @Composable () -> Unit = {}, ) { - var displayInfoDialog by rememberSaveable { mutableStateOf(false) } - var isChartExpanded by rememberSaveable { mutableStateOf(false) } + var displayInfoDialog by remember { mutableStateOf(false) } + var isChartExpanded by remember { mutableStateOf(false) } val lazyListState = rememberLazyListState() val vicoScrollState = @@ -279,15 +254,7 @@ fun BaseMetricScreen( onNavigateUp = onNavigateUp, actions = { extraActions() - if (onExportCsv != null && data.isNotEmpty()) { - IconButton(onClick = onExportCsv) { - Icon( - imageVector = MeshtasticIcons.Save, - contentDescription = stringResource(Res.string.save), - ) - } - } - IconToggleButton(checked = isChartExpanded, onCheckedChange = { isChartExpanded = it }) { + IconButton(onClick = { isChartExpanded = !isChartExpanded }) { Icon( imageVector = if (isChartExpanded) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt index da8b16e47..c1cf0e04e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt @@ -57,7 +57,7 @@ import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent * **Design principles** (per [design#53](https://github.com/meshtastic/design/issues/53)): * - Default to thin lines **without** point markers to avoid clutter on dense timeseries. * - Show a single dot only at the marker/cursor position (handled by [rememberMarker]). - * - Use `Interpolator.cubic()` for smooth monotone curves that won't overshoot between sparse points. + * - Use `Interpolator.catmullRom()` for smooth curves that pass through every data point. * - Reserve bold lines for the single most-important series; use subtle/gradient fills for secondary data. */ @Suppress("TooManyFunctions") @@ -73,21 +73,15 @@ object ChartStyling { * * @param lineColor The color of the line * @param lineWidth Width of the line in dp - * @param interpolator The line interpolation strategy. Defaults to monotone - * [cubic][LineCartesianLayer.Interpolator.cubic] which won't overshoot between sparse data points (unlike - * catmull-rom). Use [Sharp][LineCartesianLayer.Interpolator.Sharp] for discrete/integer metrics like hop counts. * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createStyledLine( - lineColor: Color, - lineWidth: Float = MEDIUM_LINE_WIDTH_DP, - interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), - ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( - fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), - stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), - interpolator = interpolator, - ) + fun createStyledLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line = + LineCartesianLayer.rememberLine( + fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), + stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), + interpolator = LineCartesianLayer.Interpolator.catmullRom(), + ) /** * Creates a line with a gradient area fill effect. Ideal for emphasising a single series or showing magnitude. The @@ -98,18 +92,14 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createGradientLine( - lineColor: Color, - lineWidth: Float = MEDIUM_LINE_WIDTH_DP, - interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), - ): LineCartesianLayer.Line { + fun createGradientLine(lineColor: Color, lineWidth: Float = MEDIUM_LINE_WIDTH_DP): LineCartesianLayer.Line { val gradientBrush = Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.05f))) return LineCartesianLayer.rememberLine( fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), areaFill = LineCartesianLayer.AreaFill.single(Fill(gradientBrush)), stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), - interpolator = interpolator, + interpolator = LineCartesianLayer.Interpolator.catmullRom(), ) } @@ -120,11 +110,8 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createBoldLine( - lineColor: Color, - interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), - ): LineCartesianLayer.Line = - createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP, interpolator = interpolator) + fun createBoldLine(lineColor: Color): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, lineWidth = THICK_LINE_WIDTH_DP) /** * Creates a subtle line suitable for secondary metrics that should not dominate the chart. @@ -144,10 +131,7 @@ object ChartStyling { * @return Configured [LineCartesianLayer.Line] */ @Composable - fun createDashedLine( - lineColor: Color, - interpolator: LineCartesianLayer.Interpolator = LineCartesianLayer.Interpolator.cubic(), - ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( + fun createDashedLine(lineColor: Color): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), stroke = LineCartesianLayer.LineStroke.Dashed( @@ -155,7 +139,7 @@ object ChartStyling { dashLength = 6.dp, gapLength = 3.dp, ), - interpolator = interpolator, + interpolator = LineCartesianLayer.Interpolator.catmullRom(), ) /** diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt index f8d48dd59..bb6efdff6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -127,8 +127,6 @@ data class LegendData( val color: Color, val isLine: Boolean = false, val metricKey: Any? = null, - /** When non-null, overrides the resolved [nameRes] string in the legend label. */ - val labelOverride: String? = null, ) data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color) @@ -155,12 +153,11 @@ fun Legend( ) { legendData.forEachIndexed { index, data -> val isVisible = index !in hiddenSet - val label = data.labelOverride ?: stringResource(data.nameRes) if (onToggle != null) { FilterChip( selected = isVisible, onClick = { onToggle(index) }, - label = { Text(text = label, style = MaterialTheme.typography.labelSmall) }, + label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) }, leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) }, modifier = Modifier.padding(horizontal = 2.dp), ) @@ -169,7 +166,7 @@ fun Legend( LegendIndicator(color = data.color, isLine = data.isLine) Spacer(modifier = Modifier.width(4.dp)) Text( - text = label, + text = stringResource(data.nameRes), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelSmall.fontSize, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 609048a92..a3fef636f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -15,6 +15,7 @@ * along with this program. If not, see . */ @file:Suppress("MagicNumber") +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics @@ -30,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -55,8 +57,6 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.MetricFormatter -import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType @@ -81,7 +81,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Cyan import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.core.ui.theme.GraphColors.Purple -import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry private enum class Device(val color: Color) { @@ -117,8 +116,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() val data = state.deviceMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() } - val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDeviceMetricsCSV(uri, data) } - val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } } val hasVoltage = remember(data) { data.any { it.device_metrics?.voltage != null } } val hasChUtil = remember(data) { data.any { it.device_metrics?.channel_utilization != null } } @@ -170,7 +167,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { timeProvider = { it.time.toDouble() }, infoData = infoItems, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.DEVICE) }, - onExportCsv = { exportLauncher("device_metrics.csv", "text/csv") }, controlPart = { TimeFrameSelector( selectedTimeFrame = timeFrame, @@ -232,13 +228,12 @@ private fun DeviceMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - val formatted = NumberFormatter.format(value, 1) when (color) { - batteryColor -> formatString(percentValueTemplate, batteryLabel, formatted) - voltageColor -> formatString(voltageValueTemplate, voltageLabel, formatted) - chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, formatted) - airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, formatted) - else -> formatString(numericValueTemplate, formatted) + batteryColor -> formatString(percentValueTemplate, batteryLabel, value) + voltageColor -> formatString(voltageValueTemplate, voltageLabel, value) + chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value) + airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value) + else -> formatString(numericValueTemplate, value) } }, ) @@ -307,13 +302,12 @@ private fun DeviceMetricsChart( } } - val percentRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0) } val leftLayer = rememberConditionalLayer( hasData = leftLayerSeriesStyles.isNotEmpty(), lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles), verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = percentRangeProvider, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0), ) val rightLayer = @@ -341,7 +335,7 @@ private fun DeviceMetricsChart( if (leftLayer != null) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = batteryColor), - valueFormatter = { _, value, _ -> MetricFormatter.percent(value.toFloat(), 0) }, + valueFormatter = { _, value, _ -> formatString("%.0f%%", value) }, ) } else { null @@ -350,7 +344,7 @@ private fun DeviceMetricsChart( if (rightLayer != null) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" }, + valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, ) } else { null @@ -445,7 +439,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick formatString( percentValueTemplate, channelUtilizationLabel, - NumberFormatter.format(deviceMetrics.channel_utilization ?: 0f, 1), + deviceMetrics.channel_utilization ?: 0f, ), ) Spacer(Modifier.width(12.dp)) @@ -457,7 +451,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick formatString( percentValueTemplate, airUtilizationLabel, - NumberFormatter.format(deviceMetrics.air_util_tx ?: 0f, 1), + deviceMetrics.air_util_tx ?: 0f, ), ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index 5029729ca..c0164dd80 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -42,7 +42,6 @@ import org.meshtastic.core.resources.baro_pressure import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.lux -import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.soil_moisture import org.meshtastic.core.resources.soil_temperature @@ -113,27 +112,6 @@ private val LEGEND_DATA_3 = ), ) -private val LEGEND_DATA_4 = - listOf( - Environment.ONE_WIRE_TEMP_1, - Environment.ONE_WIRE_TEMP_2, - Environment.ONE_WIRE_TEMP_3, - Environment.ONE_WIRE_TEMP_4, - Environment.ONE_WIRE_TEMP_5, - Environment.ONE_WIRE_TEMP_6, - Environment.ONE_WIRE_TEMP_7, - Environment.ONE_WIRE_TEMP_8, - ) - .mapIndexed { index, entry -> - LegendData( - nameRes = Res.string.one_wire_temperature, - labelOverride = "1-Wire Temp ${index + 1}", - color = entry.color, - isLine = true, - metricKey = entry, - ) - } - @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun EnvironmentMetricsChart( @@ -154,18 +132,18 @@ fun EnvironmentMetricsChart( val onSurfaceColor = MaterialTheme.colorScheme.onSurface val allLegendData = - (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4).filter { + (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter { graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0] } - // Track hidden metrics by key (not index) so toggling survives changes in allLegendData ordering. - var hiddenMetrics by remember { mutableStateOf(emptySet()) } - val hiddenIndices = - remember(hiddenMetrics, allLegendData) { - allLegendData.indices.filter { (allLegendData[it].metricKey as? Environment) in hiddenMetrics }.toSet() + // Legend toggle state: tracks indices into allLegendData that are hidden + var hiddenIndices by remember { mutableStateOf(emptySet()) } + val hiddenMetrics = + remember(hiddenIndices, allLegendData) { + hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet() } - val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) } + val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) } val showPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && Environment.BAROMETRIC_PRESSURE !in hiddenMetrics @@ -233,7 +211,6 @@ fun EnvironmentMetricsChart( }, ) - val pressureRangeProvider = remember { CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0) } val layers = mutableListOf() if (showPressure && pressureData.isNotEmpty()) { layers.add( @@ -245,7 +222,7 @@ fun EnvironmentMetricsChart( verticalAxisPosition = Axis.Position.Vertical.Start, // Fixed range per Oscar's UX guidance: barometric pressure should NOT autoscale, // otherwise trends (storms) are invisible. 700-1200 hPa covers sea-level to altitude. - rangeProvider = pressureRangeProvider, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 700.0, maxY = 1200.0), ), ) } @@ -255,7 +232,7 @@ fun EnvironmentMetricsChart( when (metric) { Environment.RADIATION, Environment.WIND_SPEED, - -> CartesianLayerRangeProvider.auto() + -> CartesianLayerRangeProvider.fixed(minY = 0.0) else -> null } val lineStyle = @@ -311,8 +288,7 @@ fun EnvironmentMetricsChart( modifier = Modifier.padding(top = 0.dp), hiddenSet = hiddenIndices, onToggle = { index -> - val metric = allLegendData.getOrNull(index)?.metricKey as? Environment ?: return@Legend - hiddenMetrics = if (metric in hiddenMetrics) hiddenMetrics - metric else hiddenMetrics + metric + hiddenIndices = if (index in hiddenIndices) hiddenIndices - index else hiddenIndices + index }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index d09bdc8d1..2b47fd5e1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -15,6 +15,7 @@ * along with this program. If not, see . */ @file:Suppress("TooManyFunctions") +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics @@ -29,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -42,7 +44,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType @@ -55,7 +56,6 @@ import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.iaq_definition import org.meshtastic.core.resources.lux -import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.rainfall_1h import org.meshtastic.core.resources.rainfall_24h @@ -71,7 +71,6 @@ import org.meshtastic.core.resources.wind_speed import org.meshtastic.core.ui.component.IaqDisplayMode import org.meshtastic.core.ui.component.IndoorAirQuality import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry @Composable @@ -82,10 +81,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() - val exportLauncher = rememberSaveFileLauncher { uri -> - viewModel.saveEnvironmentMetricsCSV(uri, filteredTelemetries) - } - BaseMetricScreen( onNavigateUp = onNavigateUp, telemetryType = TelemetryType.ENVIRONMENT, @@ -95,7 +90,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un timeProvider = { it.time.toDouble() }, infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)), onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }, - onExportCsv = { exportLauncher("environment_metrics.csv", "text/csv") }, controlPart = { TimeFrameSelector( selectedTimeFrame = timeFrame, @@ -166,10 +160,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.HUMIDITY.color) Spacer(Modifier.width(4.dp)) Text( - text = - "${stringResource( - Res.string.humidity, - )} ${MetricFormatter.percent(humidity, decimalPlaces = 2)}", + text = formatString("%s %.2f%%", stringResource(Res.string.humidity), humidity), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -182,7 +173,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot MetricIndicator(Environment.BAROMETRIC_PRESSURE.color) Spacer(Modifier.width(4.dp)) Text( - text = MetricFormatter.pressure(pressure, decimalPlaces = 2), + text = formatString("%.2f hPa", pressure), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, modifier = Modifier.padding(vertical = 0.dp), @@ -290,7 +281,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasVoltage) { val voltage = envMetrics.voltage!! Text( - text = "${stringResource(Res.string.voltage)} ${MetricFormatter.voltage(voltage)}", + text = formatString("%s %.2f V", stringResource(Res.string.voltage), voltage), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -298,10 +289,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe if (hasCurrent) { val currentValue = envMetrics.current!! Text( - text = - "${stringResource( - Res.string.current, - )} ${MetricFormatter.current(currentValue, decimalPlaces = 2)}", + text = formatString("%s %.2f mA", stringResource(Res.string.current), currentValue), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -394,11 +382,7 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { envMetrics.wind_direction!!, ) } else { - formatString( - "%s %s", - stringResource(Res.string.wind_speed), - MetricFormatter.windSpeed(envMetrics.wind_speed!!), - ) + formatString("%s %.1f m/s", stringResource(Res.string.wind_speed), envMetrics.wind_speed!!) } Text( text = dirText, @@ -414,14 +398,14 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (hasGust) { Text( - text = "${stringResource(Res.string.wind_gust)} ${MetricFormatter.windSpeed(envMetrics.wind_gust!!)}", + text = formatString("%s %.1f m/s", stringResource(Res.string.wind_gust), envMetrics.wind_gust!!), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) } if (hasLull) { Text( - text = "${stringResource(Res.string.wind_lull)} ${MetricFormatter.windSpeed(envMetrics.wind_lull!!)}", + text = formatString("%s %.1f m/s", stringResource(Res.string.wind_lull), envMetrics.wind_lull!!), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -438,10 +422,7 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (has1h) { Text( - text = - "${stringResource( - Res.string.rainfall_1h, - )} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!)}", + text = formatString("%s %.1f mm", stringResource(Res.string.rainfall_1h), envMetrics.rainfall_1h!!), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -449,9 +430,7 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) if (has24h) { Text( text = - "${stringResource( - Res.string.rainfall_24h, - )} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!)}", + formatString("%s %.1f mm", stringResource(Res.string.rainfall_24h), envMetrics.rainfall_24h!!), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -460,39 +439,6 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) } } -@Composable -private fun OneWireTemperatureDisplay( - envMetrics: org.meshtastic.proto.EnvironmentMetrics, - environmentDisplayFahrenheit: Boolean, -) { - val sensors = envMetrics.one_wire_temperature.filterNot { it.isNaN() } - if (sensors.isEmpty()) return - val oneWireEntries = - listOf( - Environment.ONE_WIRE_TEMP_1, - Environment.ONE_WIRE_TEMP_2, - Environment.ONE_WIRE_TEMP_3, - Environment.ONE_WIRE_TEMP_4, - Environment.ONE_WIRE_TEMP_5, - Environment.ONE_WIRE_TEMP_6, - Environment.ONE_WIRE_TEMP_7, - Environment.ONE_WIRE_TEMP_8, - ) - val textFormat = if (environmentDisplayFahrenheit) "%s %d: %.1f°F" else "%s %d: %.1f°C" - sensors.forEachIndexed { idx, temp -> - val color = oneWireEntries.getOrNull(idx)?.color ?: Environment.ONE_WIRE_TEMP_1.color - Row(verticalAlignment = Alignment.CenterVertically) { - MetricIndicator(color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString(textFormat, stringResource(Res.string.one_wire_temperature), idx + 1, temp), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } - } -} - @Composable private fun EnvironmentMetricsCard( telemetry: Telemetry, @@ -534,7 +480,6 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa RadiationDisplay(envMetrics) WindDisplay(envMetrics) RainfallDisplay(envMetrics) - OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt index 686a228b2..dda094e21 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt @@ -18,24 +18,16 @@ package org.meshtastic.feature.node.metrics import androidx.compose.ui.graphics.Color import org.meshtastic.core.model.util.UnitConversions -import org.meshtastic.core.ui.theme.GraphColors.Amber import org.meshtastic.core.ui.theme.GraphColors.Blue -import org.meshtastic.core.ui.theme.GraphColors.Chartreuse -import org.meshtastic.core.ui.theme.GraphColors.Coral import org.meshtastic.core.ui.theme.GraphColors.Cyan -import org.meshtastic.core.ui.theme.GraphColors.DeepOrange import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.Green -import org.meshtastic.core.ui.theme.GraphColors.Indigo import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue -import org.meshtastic.core.ui.theme.GraphColors.LightGreen import org.meshtastic.core.ui.theme.GraphColors.Lime -import org.meshtastic.core.ui.theme.GraphColors.Magenta import org.meshtastic.core.ui.theme.GraphColors.Orange import org.meshtastic.core.ui.theme.GraphColors.Pink import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.core.ui.theme.GraphColors.Red -import org.meshtastic.core.ui.theme.GraphColors.SkyBlue import org.meshtastic.core.ui.theme.GraphColors.Teal import org.meshtastic.proto.Telemetry @@ -74,39 +66,7 @@ enum class Environment(val color: Color) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.wind_speed }, RADIATION(Lime) { - override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.radiation - }, - ONE_WIRE_TEMP_1(Amber) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(0) - }, - ONE_WIRE_TEMP_2(DeepOrange) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(1) - }, - ONE_WIRE_TEMP_3(Indigo) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(2) - }, - ONE_WIRE_TEMP_4(LightGreen) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(3) - }, - ONE_WIRE_TEMP_5(Magenta) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(4) - }, - ONE_WIRE_TEMP_6(SkyBlue) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(5) - }, - ONE_WIRE_TEMP_7(Chartreuse) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(6) - }, - ONE_WIRE_TEMP_8(Coral) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(7) + override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.radiation }, ; abstract fun getValue(telemetry: Telemetry): Float? @@ -245,33 +205,6 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp shouldPlot[Environment.RADIATION.ordinal] = true } - // 1-Wire temperature sensors (up to 8 channels, Fahrenheit-aware) - val oneWireEntries = - listOf( - Environment.ONE_WIRE_TEMP_1, - Environment.ONE_WIRE_TEMP_2, - Environment.ONE_WIRE_TEMP_3, - Environment.ONE_WIRE_TEMP_4, - Environment.ONE_WIRE_TEMP_5, - Environment.ONE_WIRE_TEMP_6, - Environment.ONE_WIRE_TEMP_7, - Environment.ONE_WIRE_TEMP_8, - ) - oneWireEntries.forEach { entry -> - val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } } - if (values.isNotEmpty()) { - var minVal = values.minOf { it } - var maxVal = values.maxOf { it } - if (useFahrenheit) { - minVal = UnitConversions.celsiusToFahrenheit(minVal) - maxVal = UnitConversions.celsiusToFahrenheit(maxVal) - } - minValues.add(minVal) - maxValues.add(maxVal) - shouldPlot[entry.ordinal] = true - } - } - val min = if (minValues.isEmpty()) 0f else minValues.minOf { it } val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index 2cbf008e1..f22710ef5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults @@ -154,6 +155,7 @@ private fun HostMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: } /** Card body showing timestamp, load averages with progress bars, memory, disk, and uptime. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun HostMetricsCardContent(time: String, hostMetrics: org.meshtastic.proto.HostMetrics?) { Column(modifier = Modifier.padding(12.dp)) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt index 92e929056..653293835 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt @@ -14,6 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package org.meshtastic.feature.node.metrics import androidx.compose.foundation.BorderStroke @@ -33,6 +35,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 10a3fe427..51ef4ef8c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -23,6 +23,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull @@ -30,14 +31,16 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import okio.ByteString.Companion.decodeBase64 import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers @@ -59,13 +62,11 @@ import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.view_on_map import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.toMessageRes -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.TimeFrame -import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.Telemetry import kotlin.time.Instant @@ -104,7 +105,7 @@ open class MetricsViewModel( if (nodeId == null) return@flatMapLatest flowOf(MetricsState.Empty) getNodeDetailsUseCase(nodeId).map { it.metricsState } } - .stateInWhileSubscribed(initialValue = MetricsState.Empty) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MetricsState.Empty) private val environmentState: StateFlow = activeNodeId @@ -112,7 +113,7 @@ open class MetricsViewModel( if (nodeId == null) return@flatMapLatest flowOf(EnvironmentMetricsState()) getNodeDetailsUseCase(nodeId).map { it.environmentState } } - .stateInWhileSubscribed(initialValue = EnvironmentMetricsState()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EnvironmentMetricsState()) private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) @@ -148,8 +149,6 @@ open class MetricsViewModel( temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, soil_temperature = em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, - one_wire_temperature = - em.one_wire_temperature.map { UnitConversions.celsiusToFahrenheit(it) }, ), ) } @@ -183,8 +182,7 @@ open class MetricsViewModel( fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum) - fun deleteLog(uuid: String) = - safeLaunch(context = dispatchers.io, tag = "deleteLog") { meshLogRepository.deleteLog(uuid) } + fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { val cached = tracerouteOverlayCache.value[requestId] @@ -219,7 +217,7 @@ open class MetricsViewModel( private fun List.numSet(): Set = map { it.num }.toSet() init { - safeLaunch(tag = "tracerouteCollector") { + viewModelScope.launch { serviceRepository.tracerouteResponse.filterNotNull().collect { response -> val overlay = TracerouteOverlay( @@ -235,7 +233,7 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel created" } } - fun clearPosition() = safeLaunch(context = dispatchers.io, tag = "clearPosition") { + fun clearPosition() = viewModelScope.launch(dispatchers.io) { (manualNodeId.value ?: nodeIdFromRoute)?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) } @@ -278,8 +276,9 @@ open class MetricsViewModel( responseLogUuid: String, overlay: TracerouteOverlay?, onViewOnMap: (Int, String) -> Unit, + onShowError: (StringResource) -> Unit, ) { - safeLaunch(tag = "showTracerouteDetail") { + viewModelScope.launch { val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first() alertManager.showAlert( titleRes = Res.string.traceroute, @@ -300,11 +299,7 @@ open class MetricsViewModel( ) val errorRes = availability.toMessageRes() if (errorRes != null) { - // Post the error alert after the current alert is dismissed to avoid - // the wrapping dismissAlert() in AlertManager immediately clearing it. - safeLaunch(tag = "tracerouteError") { - alertManager.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes) - } + onShowError(errorRes) } else { onViewOnMap(requestId, responseLogUuid) } @@ -325,114 +320,35 @@ open class MetricsViewModel( Logger.d { "MetricsViewModel cleared" } } - // region --- CSV Export --- - - /** - * Shared CSV export helper. Writes [header] then iterates [rows], converting each item to a CSV line via - * [rowMapper]. The mapper returns only the data columns; date and time columns are prepended automatically from the - * epoch-seconds timestamp extracted by [epochSeconds]. - */ - private fun exportCsv( - uri: CommonUri, - header: String, - rows: List, - epochSeconds: (T) -> Long, - rowMapper: (T) -> String, - ) { - safeLaunch(context = dispatchers.io, tag = "exportCsv") { + fun savePositionCSV(uri: MeshtasticUri) { + viewModelScope.launch(dispatchers.main) { + val positions = state.value.positionLogs fileService.write(uri) { sink -> - sink.writeUtf8(header) - rows.forEach { item -> - val dt = - Instant.fromEpochSeconds(epochSeconds(item)).toLocalDateTime(TimeZone.currentSystemDefault()) - sink.writeUtf8("\"${dt.date}\",\"${dt.time}\",${rowMapper(item)}\n") + sink.writeUtf8( + "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n", + ) + + positions.forEach { position -> + val localDateTime = + Instant.fromEpochSeconds(position.time.toLong()) + .toLocalDateTime(TimeZone.currentSystemDefault()) + val rxDateTime = "\"${localDateTime.date}\",\"${localDateTime.time}\"" + + val latitude = (position.latitude_i ?: 0) * GeoConstants.DEG_D + val longitude = (position.longitude_i ?: 0) * GeoConstants.DEG_D + val altitude = position.altitude + val satsInView = position.sats_in_view + val speed = position.ground_speed + val heading = formatString("%.2f", (position.ground_track ?: 0) * GeoConstants.HEADING_DEG) + + sink.writeUtf8( + "$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n", + ) } } } } - fun savePositionCSV(uri: CommonUri, data: List) { - exportCsv( - uri = uri, - header = "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n", - rows = data, - epochSeconds = { it.time.toLong() }, - ) { pos -> - val lat = (pos.latitude_i ?: 0) * GeoConstants.DEG_D - val lon = (pos.longitude_i ?: 0) * GeoConstants.DEG_D - val heading = formatString("%.2f", (pos.ground_track ?: 0) * GeoConstants.HEADING_DEG) - "\"$lat\",\"$lon\",\"${pos.altitude}\",\"${pos.sats_in_view}\",\"${pos.ground_speed}\",\"$heading\"" - } - } - - fun saveDeviceMetricsCSV(uri: CommonUri, data: List) { - exportCsv( - uri = uri, - header = - "\"date\",\"time\",\"batteryLevel\",\"voltage\",\"channelUtilization\"," + - "\"airUtilTx\",\"uptimeSeconds\"\n", - rows = data, - epochSeconds = { it.time.toLong() }, - ) { t -> - val dm = t.device_metrics - "\"${dm?.battery_level ?: ""}\",\"${dm?.voltage ?: ""}\"," + - "\"${dm?.channel_utilization ?: ""}\",\"${dm?.air_util_tx ?: ""}\"," + - "\"${dm?.uptime_seconds ?: ""}\"" - } - } - - fun saveEnvironmentMetricsCSV(uri: CommonUri, data: List) { - val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" } - exportCsv( - uri = uri, - header = - "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," + - "\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," + - "\"soilMoisture\",$oneWireHeaders\n", - rows = data, - epochSeconds = { it.time.toLong() }, - ) { t -> - val em = t.environment_metrics - val owt = em?.one_wire_temperature ?: emptyList() - val oneWireValues = - (0 until ONE_WIRE_SENSOR_COUNT).joinToString(",") { i -> "\"${owt.getOrNull(i) ?: ""}\"" } - "\"${em?.temperature ?: ""}\",\"${em?.relative_humidity ?: ""}\"," + - "\"${em?.barometric_pressure ?: ""}\",\"${em?.gas_resistance ?: ""}\"," + - "\"${em?.iaq ?: ""}\",\"${em?.wind_speed ?: ""}\"," + - "\"${em?.wind_direction ?: ""}\",\"${em?.soil_temperature ?: ""}\"," + - "\"${em?.soil_moisture ?: ""}\",$oneWireValues" - } - } - - fun saveSignalMetricsCSV(uri: CommonUri, data: List) { - exportCsv( - uri = uri, - header = "\"date\",\"time\",\"rssi\",\"snr\"\n", - rows = data, - epochSeconds = { it.rx_time.toLong() }, - ) { p -> - "\"${p.rx_rssi}\",\"${p.rx_snr}\"" - } - } - - fun savePowerMetricsCSV(uri: CommonUri, data: List) { - exportCsv( - uri = uri, - header = - "\"date\",\"time\",\"ch1Voltage\",\"ch1Current\",\"ch2Voltage\",\"ch2Current\"," + - "\"ch3Voltage\",\"ch3Current\"\n", - rows = data, - epochSeconds = { it.time.toLong() }, - ) { t -> - val pm = t.power_metrics - "\"${pm?.ch1_voltage ?: ""}\",\"${pm?.ch1_current ?: ""}\"," + - "\"${pm?.ch2_voltage ?: ""}\",\"${pm?.ch2_current ?: ""}\"," + - "\"${pm?.ch3_voltage ?: ""}\",\"${pm?.ch3_current ?: ""}\"" - } - } - - // endregion - @Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount") fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? { try { @@ -462,8 +378,4 @@ open class MetricsViewModel( } protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0) - - companion object { - private const val ONE_WIRE_SENSOR_COUNT = 8 - } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index b3b0b36e0..cad2b63b1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -14,6 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package org.meshtastic.feature.node.metrics import androidx.compose.foundation.layout.Column @@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -141,20 +144,11 @@ private fun PaxMetricsChart( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine( - lineColor = bleColor, - interpolator = LineCartesianLayer.Interpolator.Sharp, - ), - ChartStyling.createGradientLine( - lineColor = wifiColor, - interpolator = LineCartesianLayer.Interpolator.Sharp, - ), - ChartStyling.createBoldLine( - lineColor = paxColor, - interpolator = LineCartesianLayer.Interpolator.Sharp, - ), + ChartStyling.createGradientLine(lineColor = bleColor), + ChartStyling.createGradientLine(lineColor = wifiColor), + ChartStyling.createBoldLine(lineColor = paxColor), ), - rangeProvider = CartesianLayerRangeProvider.auto(), + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), ), ), startAxis = VerticalAxis.rememberStart(label = axisLabel), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt index e2f95f04b..62ab7a0d4 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt @@ -14,32 +14,27 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - package org.meshtastic.feature.node.metrics import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.util.GeoConstants.DEG_D import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG -import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.toString import org.meshtastic.core.resources.Res @@ -48,95 +43,69 @@ import org.meshtastic.core.resources.heading import org.meshtastic.core.resources.latitude import org.meshtastic.core.resources.longitude import org.meshtastic.core.resources.sats +import org.meshtastic.core.resources.speed import org.meshtastic.core.resources.speed_kmh -import org.meshtastic.core.ui.theme.GraphColors +import org.meshtastic.core.resources.timestamp +import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.proto.Config import org.meshtastic.proto.Position -/** - * A [SelectableMetricCard]-based position item that matches the visual style of [DeviceMetricsCard], - * [SignalMetricsCard], and other metric cards. Replaces the previous table-row layout with a card that shows timestamp, - * coordinates, satellites, altitude, speed, and heading. - */ @Composable -@Suppress("LongMethod") -fun PositionCard( - position: Position, - displayUnits: Config.DisplayConfig.DisplayUnits, - isSelected: Boolean, - onClick: () -> Unit, -) { - val time = position.time.toLong() * MS_PER_SEC - val latitude = formatString("%.5f", (position.latitude_i ?: 0) * DEG_D) - val longitude = formatString("%.5f", (position.longitude_i ?: 0) * DEG_D) +private fun RowScope.PositionText(text: String, weight: Float) { + Text( + text = text, + modifier = Modifier.weight(weight), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) +} - SelectableMetricCard(isSelected = isSelected, onClick = onClick) { - Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { - /* Timestamp */ - Text( - text = DateFormatter.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) +private const val WEIGHT_10 = .10f +private const val WEIGHT_15 = .15f +private const val WEIGHT_20 = .20f +private const val WEIGHT_40 = .40f - Spacer(modifier = Modifier.height(8.dp)) - - /* Coordinates */ - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - MetricValueRow(color = GraphColors.Blue, text = "${stringResource(Res.string.latitude)}: $latitude") - Spacer(Modifier.width(12.dp)) - MetricValueRow( - color = GraphColors.Green, - text = "${stringResource(Res.string.longitude)}: $longitude", - ) - } - Text( - text = "${stringResource(Res.string.sats)}: ${position.sats_in_view}", - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - /* Alt, Speed, Heading */ - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - MetricValueRow( - color = GraphColors.Purple, - text = - "${stringResource(Res.string.alt)}: ${ - (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits) - }", - ) - if (position.ground_speed != null && position.ground_speed != 0) { - Spacer(Modifier.width(12.dp)) - MetricValueRow( - color = GraphColors.Gold, - text = stringResource(Res.string.speed_kmh, position.ground_speed ?: 0), - ) - } - } - if (position.ground_track != null && position.ground_track != 0) { - Text( - text = - "${stringResource(Res.string.heading)}: ${ - formatString("%.0f", (position.ground_track ?: 0) * HEADING_DEG) - }\u00B0", - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } - } +@Composable +fun PositionLogHeader(compactWidth: Boolean) { + Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) { + PositionText(stringResource(Res.string.latitude), WEIGHT_20) + PositionText(stringResource(Res.string.longitude), WEIGHT_20) + PositionText(stringResource(Res.string.sats), WEIGHT_10) + PositionText(stringResource(Res.string.alt), WEIGHT_15) + if (!compactWidth) { + PositionText(stringResource(Res.string.speed), WEIGHT_15) + PositionText(stringResource(Res.string.heading), WEIGHT_15) } + PositionText(stringResource(Res.string.timestamp), WEIGHT_40) + } +} + +@Composable +fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PositionText(formatString("%.5f", (position.latitude_i ?: 0) * DEG_D), WEIGHT_20) + PositionText(formatString("%.5f", (position.longitude_i ?: 0) * DEG_D), WEIGHT_20) + PositionText(position.sats_in_view.toString(), WEIGHT_10) + PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15) + if (!compactWidth) { + PositionText(stringResource(Res.string.speed_kmh, position.ground_speed ?: 0), WEIGHT_15) + PositionText(formatString("%.0f°", (position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15) + } + PositionText(position.formatPositionTime(), WEIGHT_40) + } +} + +@Composable +fun ColumnScope.PositionList( + compactWidth: Boolean, + positions: List, + displayUnits: Config.DisplayConfig.DisplayUnits, +) { + LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + items(positions) { position -> PositionItem(compactWidth, position, displayUnits) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index e414ea26d..cb7d147d2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -16,69 +16,158 @@ */ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.clear +import org.meshtastic.core.resources.collapse_chart +import org.meshtastic.core.resources.expand_chart +import org.meshtastic.core.resources.logs import org.meshtastic.core.resources.position_log +import org.meshtastic.core.resources.save +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.icon.BarChart import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.List import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh +import org.meshtastic.core.ui.icon.Save import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider -import org.meshtastic.core.ui.util.rememberSaveFileLauncher +@Composable +private fun ActionButtons( + clearButtonEnabled: Boolean, + onClear: () -> Unit, + saveButtonEnabled: Boolean, + onSave: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onClear, + enabled = clearButtonEnabled, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear)) + Spacer(Modifier.width(8.dp)) + Text(text = stringResource(Res.string.clear)) + } + + OutlinedButton(modifier = Modifier.weight(1f), onClick = onSave, enabled = saveButtonEnabled) { + Icon(imageVector = MeshtasticIcons.Save, contentDescription = stringResource(Res.string.save)) + Spacer(Modifier.width(8.dp)) + Text(text = stringResource(Res.string.save)) + } + } +} + +@Suppress("LongMethod") @Composable fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() - val positions = state.positionLogs - val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) } + val exportPositionLauncher = + org.meshtastic.core.ui.util.rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri) } + + var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) } + var isMapExpanded by remember { mutableStateOf(false) } val trackMap = LocalNodeTrackMapProvider.current val destNum = state.node?.num ?: 0 - BaseMetricScreen( - onNavigateUp = onNavigateUp, - telemetryType = null, - titleRes = Res.string.position_log, - nodeName = state.node?.user?.long_name ?: "", - data = positions, - timeProvider = { it.time.toDouble() }, - onExportCsv = { exportPositionLauncher("position.csv", "text/csv") }, - extraActions = { - if (positions.isNotEmpty()) { - IconButton(onClick = { viewModel.clearPosition() }) { - Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear)) - } - } - if (!state.isLocal) { - IconButton(onClick = { viewModel.requestPosition() }) { - Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) - } - } + Scaffold( + topBar = { + MainAppBar( + title = state.node?.user?.long_name ?: "", + subtitle = + stringResource(Res.string.position_log) + + " (${state.positionLogs.size} ${stringResource(Res.string.logs)})", + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = { + IconButton(onClick = { isMapExpanded = !isMapExpanded }) { + Icon( + imageVector = if (isMapExpanded) MeshtasticIcons.List else MeshtasticIcons.BarChart, + contentDescription = + stringResource( + if (isMapExpanded) Res.string.collapse_chart else Res.string.expand_chart, + ), + ) + } + if (!state.isLocal) { + IconButton(onClick = { viewModel.requestPosition() }) { + Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) + } + } + }, + onClickChip = {}, + ) }, - chartPart = { modifier, selectedX, _, onPointSelected -> - val selectedTime = selectedX?.toInt() - trackMap(destNum, positions, modifier, selectedTime) { time -> onPointSelected(time.toDouble()) } - }, - listPart = { modifier, selectedX, lazyListState, onCardClick -> - LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { - itemsIndexed(positions) { _, position -> - PositionCard( - position = position, - displayUnits = state.displayUnits, - isSelected = position.time.toDouble() == selectedX, - onClick = { onCardClick(position.time.toDouble()) }, - ) - } - } - }, - ) + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding)) { + AdaptiveMetricLayout( + isChartExpanded = isMapExpanded, + chartPart = { modifier -> trackMap(destNum, state.positionLogs, modifier) }, + listPart = { modifier -> + BoxWithConstraints(modifier = modifier) { + val compactWidth = maxWidth < 600.dp + Column { + val textStyle = + if (compactWidth) { + MaterialTheme.typography.bodySmall + } else { + LocalTextStyle.current + } + CompositionLocalProvider(LocalTextStyle provides textStyle) { + PositionLogHeader(compactWidth) + PositionList(compactWidth, state.positionLogs, state.displayUnits) + } + + ActionButtons( + clearButtonEnabled = clearButtonEnabled, + onClear = { + clearButtonEnabled = false + viewModel.clearPosition() + }, + saveButtonEnabled = state.hasPositionLogs(), + onSave = { exportPositionLauncher("position.csv", "text/csv") }, + ) + } + } + }, + ) + } + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index 5a71659f8..ebfae8407 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -15,6 +15,7 @@ * along with this program. If not, see . */ @file:Suppress("MagicNumber") +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics @@ -30,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -38,7 +40,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -54,8 +55,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.MetricFormatter -import org.meshtastic.core.common.util.NumberFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res @@ -72,7 +72,6 @@ import org.meshtastic.core.resources.power_metrics_log import org.meshtastic.core.resources.voltage import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue -import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.Telemetry private enum class PowerMetric(val color: Color) { @@ -104,16 +103,13 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() val data = state.powerMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() } - - val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.savePowerMetricsCSV(uri, data) } - val availableChannels = remember(data) { PowerChannel.entries.filter { channel -> data.any { !retrieveVoltage(channel, it).isNaN() || !retrieveCurrent(channel, it).isNaN() } } } - var selectedChannel by rememberSaveable { mutableStateOf(PowerChannel.ONE) } + var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) } BaseMetricScreen( onNavigateUp = onNavigateUp, @@ -123,7 +119,6 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { data = data, timeProvider = { it.time.toDouble() }, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) }, - onExportCsv = { exportLauncher("power_metrics.csv", "text/csv") }, controlPart = { Column { TimeFrameSelector( @@ -182,10 +177,12 @@ private fun PowerMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) { - modelProducer, - chartModifier, - -> + MetricChartScaffold( + isEmpty = telemetries.isEmpty(), + legendData = LEGEND_DATA, + modifier = modifier, + key = selectedChannel, + ) { modelProducer, chartModifier -> val currentColor = PowerMetric.CURRENT.color val voltageColor = PowerMetric.VOLTAGE.color val marker = @@ -193,9 +190,9 @@ private fun PowerMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> when (color) { - currentColor -> "Current: ${MetricFormatter.current(value.toFloat(), 0)}" - voltageColor -> "Voltage: ${NumberFormatter.format(value.toFloat(), 1)} V" - else -> NumberFormatter.format(value.toFloat(), 1) + currentColor -> formatString("Current: %.0f mA", value) + voltageColor -> formatString("Voltage: %.1f V", value) + else -> formatString("%.1f", value) } }, ) @@ -255,7 +252,7 @@ private fun PowerMetricsChart( if (currentData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = currentColor), - valueFormatter = { _, value, _ -> MetricFormatter.current(value.toFloat(), 0) }, + valueFormatter = { _, value, _ -> formatString("%.0f mA", value) }, ) } else { null @@ -264,7 +261,7 @@ private fun PowerMetricsChart( if (voltageData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = voltageColor), - valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" }, + valueFormatter = { _, value, _ -> formatString("%.1f V", value) }, ) } else { null @@ -368,8 +365,8 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current style = TextStyle(fontWeight = FontWeight.Bold), fontSize = MaterialTheme.typography.labelLarge.fontSize, ) - MetricValueRow(color = PowerMetric.VOLTAGE.color, text = MetricFormatter.voltage(voltage)) - MetricValueRow(color = PowerMetric.CURRENT.color, text = MetricFormatter.current(current)) + MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage)) + MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current)) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 4931d8c59..376b55289 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -14,6 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + package org.meshtastic.feature.node.metrics import androidx.compose.foundation.layout.Arrangement @@ -29,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -47,17 +50,18 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.rssi +import org.meshtastic.core.resources.rssi_definition import org.meshtastic.core.resources.signal_quality import org.meshtastic.core.resources.snr +import org.meshtastic.core.resources.snr_definition import org.meshtastic.core.ui.component.LoraSignalIndicator import org.meshtastic.core.ui.theme.GraphColors.Blue import org.meshtastic.core.ui.theme.GraphColors.Green -import org.meshtastic.core.ui.util.rememberSaveFileLauncher import org.meshtastic.proto.MeshPacket private enum class SignalMetric(val color: Color) { @@ -79,8 +83,6 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() val data = state.signalMetrics.filter { it.rx_time.toLong() >= timeFrame.timeThreshold() } - val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveSignalMetricsCSV(uri, data) } - BaseMetricScreen( onNavigateUp = onNavigateUp, telemetryType = TelemetryType.LOCAL_STATS, @@ -89,7 +91,11 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { data = data, timeProvider = { it.rx_time.toDouble() }, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }, - onExportCsv = { exportLauncher("signal_metrics.csv", "text/csv") }, + infoData = + listOf( + InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color), + InfoDialogData(Res.string.rssi, Res.string.rssi_definition, SignalMetric.RSSI.color), + ), controlPart = { TimeFrameSelector( selectedTimeFrame = timeFrame, @@ -157,9 +163,9 @@ private fun SignalMetricsChart( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> if (color == rssiColor) { - "RSSI: ${MetricFormatter.rssi(value.toInt())}" + formatString("RSSI: %.0f dBm", value) } else { - "SNR: ${MetricFormatter.snr(value.toFloat())}" + formatString("SNR: %.1f dB", value) } }, ) @@ -189,7 +195,7 @@ private fun SignalMetricsChart( if (rssiData.isNotEmpty()) { VerticalAxis.rememberStart( label = ChartStyling.rememberAxisLabel(color = rssiColor), - valueFormatter = { _, value, _ -> MetricFormatter.rssi(value.toInt()) }, + valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) }, ) } else { null @@ -198,7 +204,7 @@ private fun SignalMetricsChart( if (snrData.isNotEmpty()) { VerticalAxis.rememberEnd( label = ChartStyling.rememberAxisLabel(color = snrColor), - valueFormatter = { _, value, _ -> MetricFormatter.snr(value.toFloat()) }, + valueFormatter = { _, value, _ -> formatString("%.1f dB", value) }, ) } else { null @@ -234,9 +240,15 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli /* SNR and RSSI */ Row(verticalAlignment = Alignment.CenterVertically) { - MetricValueRow(color = SignalMetric.RSSI.color, text = MetricFormatter.rssi(meshPacket.rx_rssi)) + MetricValueRow( + color = SignalMetric.RSSI.color, + text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()), + ) Spacer(Modifier.width(12.dp)) - MetricValueRow(color = SignalMetric.SNR.color, text = MetricFormatter.snr(meshPacket.rx_snr)) + MetricValueRow( + color = SignalMetric.SNR.color, + text = formatString("%.1f dB", meshPacket.rx_snr), + ) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt index c27f111d1..ce6300205 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt @@ -112,7 +112,7 @@ internal fun resolveTraceroutePoints(requests: List, results: List, scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { val nodeListViewModel: NodeListViewModel = koinViewModel() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index 233942f00..facb5a9d7 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -19,7 +19,6 @@ package org.meshtastic.feature.node.navigation import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable -import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -73,7 +72,7 @@ import kotlin.reflect.KClass fun EntryProviderScope.nodesGraph( backStack: NavBackStack, scrollToTopEvents: Flow = MutableSharedFlow(), - onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { entry(metadata = { ListDetailSceneStrategy.listPane() }) { AdaptiveNodeListScreen( @@ -99,7 +98,7 @@ fun EntryProviderScope.nodesGraph( fun EntryProviderScope.nodeDetailGraph( backStack: NavBackStack, scrollToTopEvents: Flow, - onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, + onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> }, ) { entry(metadata = { ListDetailSceneStrategy.listPane() }) { args -> AdaptiveNodeListScreen( @@ -117,9 +116,9 @@ fun EntryProviderScope.nodeDetailGraph( nodeId = destNum, viewModel = nodeDetailViewModel, compassViewModel = compassViewModel, - navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) }, - onNavigate = { route -> backStack.add(route) }, - onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + navigateToMessages = { backStack.add(ContactsRoute.Messages(it)) }, + onNavigate = { backStack.add(it) }, + onNavigateUp = { backStack.removeLastOrNull() }, ) } @@ -129,7 +128,7 @@ fun EntryProviderScope.nodeDetailGraph( TracerouteLogScreen( viewModel = metricsViewModel, - onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onNavigateUp = { backStack.removeLastOrNull() }, onViewOnMap = { requestId, responseLogUuid -> backStack.add( NodeDetailRoute.TracerouteMap( @@ -183,7 +182,7 @@ private inline fun EntryProviderScope.addNodeDetailS val metricsViewModel = koinViewModel { parametersOf(destNum) } metricsViewModel.setNodeId(destNum) - routeInfo.screenComposable(metricsViewModel, dropUnlessResumed { backStack.removeLastOrNull() }) + routeInfo.screenComposable(metricsViewModel) { backStack.removeLastOrNull() } } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt deleted file mode 100644 index 6bca8822b..000000000 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.node.detail - -import androidx.lifecycle.SavedStateHandle -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.meshtastic.core.model.Node -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.feature.node.component.NodeMenuAction -import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase -import org.meshtastic.feature.node.model.NodeDetailAction -import org.meshtastic.proto.User -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertFalse - -@OptIn(ExperimentalCoroutinesApi::class) -class HandleNodeActionTest { - - private val testDispatcher = UnconfinedTestDispatcher() - private val nodeManagementActions: NodeManagementActions = mock() - private val nodeRequestActions: NodeRequestActions = mock() - private val serviceRepository: ServiceRepository = mock() - private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() - - @BeforeTest - fun setUp() { - Dispatchers.setMain(testDispatcher) - every { getNodeDetailsUseCase(any()) } returns emptyFlow() - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `remove action delegates to viewModel and does not navigate up immediately`() = runTest(testDispatcher) { - val node = Node(num = 1234, user = User(id = "!1234")) - every { nodeManagementActions.requestRemoveNode(any(), any(), any()) } returns Unit - val viewModel = createViewModel() - var navigateUpCalled = false - - handleNodeAction( - action = NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node)), - uiState = NodeDetailUiState(), - navigateToMessages = {}, - onNavigateUp = { navigateUpCalled = true }, - onNavigate = {}, - viewModel = viewModel, - ) - - verify { nodeManagementActions.requestRemoveNode(any(), node, any()) } - assertFalse(navigateUpCalled) - } - - private fun createViewModel() = NodeDetailViewModel( - savedStateHandle = SavedStateHandle(mapOf("destNum" to 1234)), - nodeManagementActions = nodeManagementActions, - nodeRequestActions = nodeRequestActions, - serviceRepository = serviceRepository, - getNodeDetailsUseCase = getNodeDetailsUseCase, - ) -} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 3212a313e..89015c807 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -30,7 +30,6 @@ import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.proto.User import kotlin.test.Test -import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class NodeManagementActionsTest { @@ -70,23 +69,4 @@ class NodeManagementActionsTest { ) } } - - @Test - fun requestRemoveNode_invokes_onAfterRemove_when_user_confirms() { - val realAlertManager = AlertManager() - val actionsWithRealAlert = - NodeManagementActions( - nodeRepository = nodeRepository, - serviceRepository = serviceRepository, - radioController = radioController, - alertManager = realAlertManager, - ) - val node = Node(num = 123, user = User(long_name = "Test Node")) - var afterRemoveCalled = false - - actionsWithRealAlert.requestRemoveNode(testScope, node) { afterRemoveCalled = true } - realAlertManager.currentAlert.value?.onConfirm?.invoke() - - assertTrue(afterRemoveCalled) - } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt index 9511a2da1..602134aa0 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt @@ -32,7 +32,6 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController -import org.meshtastic.core.testing.FakeRadioInterfaceService import org.meshtastic.core.testing.TestDataFactory import org.meshtastic.feature.node.detail.NodeManagementActions import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase @@ -46,7 +45,6 @@ class NodeListViewModelTest { private lateinit var viewModel: NodeListViewModel private lateinit var nodeRepository: FakeNodeRepository private lateinit var radioController: FakeRadioController - private lateinit var radioInterfaceService: FakeRadioInterfaceService private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) private val nodeFilterPreferences: NodeFilterPreferences = mock(MockMode.autofill) @@ -57,7 +55,6 @@ class NodeListViewModelTest { fun setUp() { nodeRepository = FakeNodeRepository() radioController = FakeRadioController() - radioInterfaceService = FakeRadioInterfaceService() every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(org.meshtastic.proto.DeviceProfile()) @@ -82,7 +79,6 @@ class NodeListViewModelTest { radioConfigRepository = radioConfigRepository, serviceRepository = serviceRepository, radioController = radioController, - radioInterfaceService = radioInterfaceService, nodeManagementActions = nodeManagementActions, getFilteredNodesUseCase = getFilteredNodesUseCase, nodeFilterPreferences = nodeFilterPreferences, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index 956c20175..34e411af0 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import okio.Buffer import okio.BufferedSink -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogRepository @@ -210,8 +210,8 @@ class MetricsViewModelTest { awaitItem() // Empty awaitItem() // with position - val uri = CommonUri.parse("content://test") - vm.savePositionCSV(uri, listOf(testPosition)) + val uri = MeshtasticUri("content://test") + vm.savePositionCSV(uri) runCurrent() verifySuspend { fileService.write(uri, any()) } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt index a80b2172e..060925fb3 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/TracerouteChartTest.kt @@ -241,19 +241,6 @@ class TracerouteChartTest { assertNull(point.returnHops) } - @Test - fun timeSeconds_truncatesSubSecondPrecision() { - // received_date with sub-second remainder (e.g. 1000 seconds + 456 ms) - val requestTime = 1000L * MS_PER_SEC + 456L - val requests = listOf(makeRequest(id = 1, receivedDateMillis = requestTime)) - val results = emptyList() - - val point = resolveTraceroutePoints(requests, results).first() - - // Must truncate to whole seconds to avoid Vico "x-values are too precise" crash - assertEquals(1000.0, point.timeSeconds) - } - @Test fun returnHops_computedWhenRouteBackAvailable() { val requests = listOf(makeRequest(id = 1, receivedDateMillis = 1000L * MS_PER_SEC)) diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index c33a6f353..4b868fbc4 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -18,7 +18,6 @@ plugins { alias(libs.plugins.meshtastic.kmp.feature) alias(libs.plugins.meshtastic.kotlinx.serialization) - id("meshtastic.kmp.jvm.android") } kotlin { @@ -27,6 +26,7 @@ kotlin { android { namespace = "org.meshtastic.feature.settings" androidResources.enable = false + withHostTest { isIncludeAndroidResources = true } } sourceSets { @@ -57,11 +57,17 @@ kotlin { implementation(libs.androidx.appcompat) } - commonTest.dependencies { - implementation(project(":core:datastore")) - implementation(libs.compose.multiplatform.ui.test) - } + commonTest.dependencies { implementation(project(":core:datastore")) } - jvmTest.dependencies { implementation(compose.desktop.currentOs) } + val androidHostTest by getting { + dependencies { + implementation(project(":core:datastore")) + implementation(libs.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.androidx.compose.ui.test.manifest) + implementation(libs.androidx.test.ext.junit) + } + } } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt b/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt similarity index 71% rename from feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt rename to feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt index 83bcddee1..b768528e9 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt +++ b/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt @@ -23,14 +23,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_active_filters import org.meshtastic.core.resources.debug_default_search @@ -39,15 +42,18 @@ import org.meshtastic.core.resources.getString import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchMatch import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchState -import kotlin.test.Test +import org.robolectric.annotation.Config -@OptIn(ExperimentalTestApi::class) +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34]) class DebugSearchTest { + @get:Rule val composeTestRule = createComposeRule() + @Test - fun debugSearchBar_showsPlaceholder() = runComposeUiTest { + fun debugSearchBar_showsPlaceholder() { val placeholder = getString(Res.string.debug_default_search) - setContent { + composeTestRule.setContent { DebugSearchBar( searchState = SearchState(), onSearchTextChange = {}, @@ -56,13 +62,13 @@ class DebugSearchTest { onClearSearch = {}, ) } - onNodeWithText(placeholder).assertIsDisplayed() + composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() } @Test - fun debugSearchBar_showsClearButtonWhenTextEntered() = runComposeUiTest { + fun debugSearchBar_showsClearButtonWhenTextEntered() { val placeholder = getString(Res.string.debug_default_search) - setContent { + composeTestRule.setContent { var searchText by remember { mutableStateOf("test") } DebugSearchBar( searchState = SearchState(searchText = searchText), @@ -72,17 +78,17 @@ class DebugSearchTest { onClearSearch = { searchText = "" }, ) } - onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick() - onNodeWithText(placeholder).assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick() + composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() } @Test - fun debugSearchBar_searchFor_showsArrowsClearAndValues() = runComposeUiTest { + fun debugSearchBar_searchFor_showsArrowsClearAndValues() { val searchText = "test" val matchCount = 3 val currentMatchIndex = 1 - setContent { + composeTestRule.setContent { DebugSearchBar( searchState = SearchState( @@ -98,18 +104,18 @@ class DebugSearchTest { ) } // Check the match count display (e.g., '2/3') - onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() + composeTestRule.onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() // Check the navigation arrows - onNodeWithContentDescription("Previous match").assertIsDisplayed() - onNodeWithContentDescription("Next match").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Previous match").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Next match").assertIsDisplayed() // Check the clear button - onNodeWithContentDescription("Clear search").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed() } @Test - fun debugFilterBar_showsFilterButtonAndMenu() = runComposeUiTest { + fun debugFilterBar_showsFilterButtonAndMenu() { val filterLabel = getString(Res.string.debug_filters) - setContent { + composeTestRule.setContent { var filterTexts by remember { mutableStateOf(listOf()) } var customFilterText by remember { mutableStateOf("") } val presetFilters = listOf("Error", "Warning", "Info") @@ -132,13 +138,13 @@ class DebugSearchTest { ) } // The filter button should be visible - onNodeWithText(filterLabel).assertIsDisplayed() + composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed() } @Test - fun debugFilterBar_addCustomFilter_displaysActiveFilter() = runComposeUiTest { + fun debugFilterBar_addCustomFilter_displaysActiveFilter() { val activeFiltersLabel = getString(Res.string.debug_active_filters) - setContent { + composeTestRule.setContent { var filterTexts by remember { mutableStateOf(listOf()) } var customFilterText by remember { mutableStateOf("") } Column(modifier = Modifier.padding(16.dp)) { @@ -156,16 +162,18 @@ class DebugSearchTest { ) } } - onNodeWithText("Add custom filter").performTextInput("MyFilter") - onNodeWithContentDescription("Add filter").performClick() - onNodeWithText(activeFiltersLabel).assertIsDisplayed() - onNodeWithText("MyFilter").assertIsDisplayed() + with(composeTestRule) { + onNodeWithText("Add custom filter").performTextInput("MyFilter") + onNodeWithContentDescription("Add filter").performClick() + onNodeWithText(activeFiltersLabel).assertIsDisplayed() + onNodeWithText("MyFilter").assertIsDisplayed() + } } @Test - fun debugActiveFilters_clearAllFilters_removesFilters() = runComposeUiTest { + fun debugActiveFilters_clearAllFilters_removesFilters() { val activeFiltersLabel = getString(Res.string.debug_active_filters) - setContent { + composeTestRule.setContent { var filterTexts by remember { mutableStateOf(listOf("A", "B")) } DebugActiveFilters( filterTexts = filterTexts, @@ -175,13 +183,13 @@ class DebugSearchTest { ) } // The active filters label and chips should be visible - onNodeWithText(activeFiltersLabel).assertIsDisplayed() - onNodeWithText("A").assertIsDisplayed() - onNodeWithText("B").assertIsDisplayed() + composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() + composeTestRule.onNodeWithText("A").assertIsDisplayed() + composeTestRule.onNodeWithText("B").assertIsDisplayed() // Click the clear all filters button - onNodeWithContentDescription("Clear all filters").performClick() + composeTestRule.onNodeWithContentDescription("Clear all filters").performClick() // The filter chips should no longer be visible - onNodeWithText("A").assertDoesNotExist() - onNodeWithText("B").assertDoesNotExist() + composeTestRule.onNodeWithText("A").assertDoesNotExist() + composeTestRule.onNodeWithText("B").assertDoesNotExist() } } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 82cd4b7be..c33c3a293 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -30,16 +30,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.eygraber.uri.toKmpUri +import com.google.accompanist.permissions.ExperimentalPermissionsApi import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.toDate import org.meshtastic.core.common.util.toInstant +import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.WifiProvisionRoute @@ -57,7 +57,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.settings.component.AppInfoSection import org.meshtastic.feature.settings.component.AppearanceSection -import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.PersistenceSection import org.meshtastic.feature.settings.component.PrivacySection @@ -73,6 +72,7 @@ import org.meshtastic.proto.DeviceProfile import java.text.SimpleDateFormat import java.util.Locale +@OptIn(ExperimentalPermissionsApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun SettingsScreen( @@ -90,14 +90,14 @@ fun SettingsScreen( val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var deviceProfile by remember { mutableStateOf(null) } - var showEditDeviceProfileDialog by rememberSaveable { mutableStateOf(false) } + var showEditDeviceProfileDialog by remember { mutableStateOf(false) } val importConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { showEditDeviceProfileDialog = true it.data?.data?.let { uri -> - viewModel.importProfile(uri.toKmpUri()) { profile -> deviceProfile = profile } + viewModel.importProfile(uri.toMeshtasticUri()) { profile -> deviceProfile = profile } } } } @@ -105,7 +105,7 @@ fun SettingsScreen( val exportConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportProfile(uri.toKmpUri(), deviceProfile!!) } + it.data?.data?.let { uri -> viewModel.exportProfile(uri.toMeshtasticUri(), deviceProfile!!) } } } @@ -144,12 +144,12 @@ fun SettingsScreen( ) } - var showLanguagePickerDialog by rememberSaveable { mutableStateOf(false) } + var showLanguagePickerDialog by remember { mutableStateOf(false) } if (showLanguagePickerDialog) { LanguagePickerDialog { showLanguagePickerDialog = false } } - var showThemePickerDialog by rememberSaveable { mutableStateOf(false) } + var showThemePickerDialog by remember { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -157,14 +157,6 @@ fun SettingsScreen( ) } - var showContrastPickerDialog by remember { mutableStateOf(false) } - if (showContrastPickerDialog) { - ContrastPickerDialog( - onClickContrast = { settingsViewModel.setContrastLevel(it) }, - onDismiss = { showContrastPickerDialog = false }, - ) - } - Scaffold( topBar = { MainAppBar( @@ -237,7 +229,6 @@ fun SettingsScreen( AppearanceSection( onShowLanguagePicker = { showLanguagePickerDialog = true }, onShowThemePicker = { showThemePickerDialog = true }, - onShowContrastPicker = { showContrastPickerDialog = true }, ) ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { @@ -250,7 +241,7 @@ fun SettingsScreen( cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value, onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) }, nodeShortName = ourNode?.user?.short_name ?: "", - onExportData = { settingsViewModel.saveDataCsv(it.toKmpUri()) }, + onExportData = { settingsViewModel.saveDataCsv(it.toMeshtasticUri()) }, ) AppInfoSection( diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt index cb61c8295..f70cda978 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt @@ -28,7 +28,6 @@ import androidx.core.net.toUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_settings -import org.meshtastic.core.resources.contrast import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.theme import org.meshtastic.core.ui.component.ListItem @@ -38,13 +37,9 @@ import org.meshtastic.core.ui.icon.Language import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.AppTheme -/** Section for app appearance settings like language, theme, and contrast. */ +/** Section for app appearance settings like language and theme. */ @Composable -fun AppearanceSection( - onShowLanguagePicker: () -> Unit, - onShowThemePicker: () -> Unit, - onShowContrastPicker: () -> Unit, -) { +fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) { val context = LocalContext.current val settingsLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {} @@ -79,19 +74,11 @@ fun AppearanceSection( ) { onShowThemePicker() } - - ListItem( - text = stringResource(Res.string.contrast), - leadingIcon = MeshtasticIcons.FormatPaint, - trailingIcon = null, - ) { - onShowContrastPicker() - } } } @Preview(showBackground = true) @Composable private fun AppearanceSectionPreview() { - AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}, onShowContrastPicker = {}) } + AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt similarity index 64% rename from feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt rename to feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt index 3930580d1..d7910f2ea 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt @@ -16,9 +16,15 @@ */ package org.meshtastic.feature.settings.component +import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.analytics_okay import org.meshtastic.core.resources.app_settings @@ -28,12 +34,11 @@ import org.meshtastic.core.ui.component.SwitchListItem import org.meshtastic.core.ui.icon.BugReport import org.meshtastic.core.ui.icon.LocationOn import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.util.isGpsDisabled -import org.meshtastic.core.ui.util.isLocationPermissionGranted -import org.meshtastic.core.ui.util.rememberRequestLocationPermission -import org.meshtastic.core.ui.util.rememberShowToastResource +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.util.showToast /** Section managing privacy settings like analytics and location sharing. */ +@OptIn(ExperimentalPermissionsApi::class) @Composable fun PrivacySection( analyticsAvailable: Boolean, @@ -46,22 +51,21 @@ fun PrivacySection( startProvideLocation: () -> Unit, stopProvideLocation: () -> Unit, ) { - val showToast = rememberShowToastResource() - val isLocationGranted = isLocationPermissionGranted() - val isGpsOff = isGpsDisabled() - val requestLocationPermission = - rememberRequestLocationPermission(onGranted = { startProvideLocation() }, onDenied = {}) + val context = LocalContext.current + val locationPermissionsState = + rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) + val isGpsDisabled = context.gpsDisabled() - LaunchedEffect(provideLocation, isLocationGranted, isGpsOff) { + LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) { if (provideLocation) { - if (isLocationGranted) { - if (!isGpsOff) { + if (locationPermissionsState.allPermissionsGranted) { + if (!isGpsDisabled) { startProvideLocation() } else { - showToast(Res.string.location_disabled) + context.showToast(Res.string.location_disabled) } } else { - requestLocationPermission() + locationPermissionsState.launchMultiplePermissionRequest() } } else { stopProvideLocation() @@ -81,7 +85,7 @@ fun PrivacySection( SwitchListItem( text = stringResource(Res.string.provide_location_to_mesh), leadingIcon = MeshtasticIcons.LocationOn, - enabled = !isGpsOff, + enabled = !isGpsDisabled, checked = provideLocation, onClick = { onToggleLocation(!provideLocation) }, ) @@ -89,3 +93,21 @@ fun PrivacySection( HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph) } } + +@Preview(showBackground = true) +@Composable +private fun PrivacySectionPreview() { + AppTheme { + PrivacySection( + analyticsAvailable = true, + analyticsEnabled = true, + onToggleAnalytics = {}, + provideLocation = true, + onToggleLocation = {}, + homoglyphEnabled = false, + onToggleHomoglyph = {}, + startProvideLocation = {}, + stopProvideLocation = {}, + ) + } +} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 315ad1da8..c251b4d5e 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -27,7 +27,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_export_failed import org.meshtastic.core.resources.debug_export_success @@ -49,7 +48,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { try { if (logs.isEmpty()) { withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } diff --git a/feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt similarity index 100% rename from feature/settings/src/jvmAndroidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt rename to feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt index 063add0d1..fe5e381f6 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/ExternalNotificationConfigScreen.android.kt @@ -30,26 +30,17 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.import_label import org.meshtastic.core.resources.play -import org.meshtastic.core.resources.ringtone_file_empty -import org.meshtastic.core.resources.ringtone_import_error -import org.meshtastic.core.resources.ringtone_imported import org.meshtastic.core.ui.icon.FolderOpen import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.PlayArrow import java.io.File private const val MAX_RINGTONE_SIZE = 230 -private const val IMPORT_ERROR_PLACEHOLDER = "@@ERROR@@" @Suppress("TooGenericExceptionCaught") @Composable actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (String) -> Unit, enabled: Boolean) { val context = LocalContext.current - val importedText = stringResource(Res.string.ringtone_imported) - val emptyText = stringResource(Res.string.ringtone_file_empty) - // Pre-resolve the format pattern for use in the non-composable launcher callback. - // Using a sentinel placeholder that will be replaced at call-site. - val importErrorPrefix = stringResource(Res.string.ringtone_import_error, IMPORT_ERROR_PLACEHOLDER) val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> @@ -61,16 +52,15 @@ actual fun RingtoneTrailingIcon(ringtoneInput: String, onRingtoneImported: (Stri val read = reader.read(buffer) if (read > 0) { onRingtoneImported(String(buffer, 0, read)) - Toast.makeText(context, importedText, Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Imported ringtone", Toast.LENGTH_SHORT).show() } else { - Toast.makeText(context, emptyText, Toast.LENGTH_SHORT).show() + Toast.makeText(context, "File is empty", Toast.LENGTH_SHORT).show() } } } } catch (e: Exception) { Logger.e(e) { "Error importing ringtone" } - val errorMsg = importErrorPrefix.replace(IMPORT_ERROR_PLACEHOLDER, e.message ?: e.toString()) - Toast.makeText(context, errorMsg, Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Error importing: ${e.message}", Toast.LENGTH_SHORT).show() } } } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt index 15cd0e11d..96e6890b2 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt @@ -30,9 +30,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.eygraber.uri.toKmpUri import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.export_keys import org.meshtastic.core.resources.export_keys_confirmation @@ -54,7 +54,7 @@ actual fun ExportSecurityConfigButton( val exportConfigLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { - it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toKmpUri(), securityConfig) } + it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), securityConfig) } } } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt index a28a57678..9afde85e5 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt @@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.meshtastic.core.common.util.ioDispatcher @Composable actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { @@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr return { fileName -> exportLauncher.launch(fileName) } } -private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { +private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { try { context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } Logger.i { "TAK data package exported successfully to $targetUri" } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt index 723448897..499874e26 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/TakPermissionUtil.kt @@ -23,7 +23,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -private val SDK_INT_ANDROID_16 = Build.VERSION_CODES.BAKLAVA +private const val SDK_INT_ANDROID_16 = 37 @OptIn(ExperimentalPermissionsApi::class) @Composable diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt similarity index 54% rename from feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt rename to feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt index cffeab006..1f390e44e 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt +++ b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt @@ -16,24 +16,27 @@ */ package org.meshtastic.feature.settings.radio.component -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.save import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.Position -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -@OptIn(ExperimentalTestApi::class) +@RunWith(AndroidJUnit4::class) class EditDeviceProfileDialogTest { + @get:Rule val composeTestRule = createComposeRule() + private val title = "Export configuration" private val deviceProfile = DeviceProfile( @@ -43,61 +46,61 @@ class EditDeviceProfileDialogTest { fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138), ) - @Test - fun testEditDeviceProfileDialog_showsDialogTitle() = runComposeUiTest { - setContent { - EditDeviceProfileDialog(title = title, deviceProfile = deviceProfile, onConfirm = {}, onDismiss = {}) - } - - // Verify that the dialog title is displayed - onNodeWithText(title).assertIsDisplayed() - } - - @Test - fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() = runComposeUiTest { - setContent { - EditDeviceProfileDialog(title = title, deviceProfile = deviceProfile, onConfirm = {}, onDismiss = {}) - } - - // Verify the "Cancel" and "Save" buttons are displayed - onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed() - onNodeWithText(getString(Res.string.save)).assertIsDisplayed() - } - - @Test - fun testEditDeviceProfileDialog_clickCancelButton() = runComposeUiTest { - var onDismissClicked = false - setContent { + private fun testEditDeviceProfileDialog(onDismiss: () -> Unit = {}, onConfirm: (DeviceProfile) -> Unit = {}) = + composeTestRule.setContent { EditDeviceProfileDialog( title = title, deviceProfile = deviceProfile, - onConfirm = {}, - onDismiss = { onDismissClicked = true }, + onConfirm = onConfirm, + onDismiss = onDismiss, ) } - // Click the "Cancel" button - onNodeWithText(getString(Res.string.cancel)).performClick() + @Test + fun testEditDeviceProfileDialog_showsDialogTitle() { + composeTestRule.apply { + testEditDeviceProfileDialog() + + // Verify that the dialog title is displayed + onNodeWithText(title).assertIsDisplayed() + } + } + + @Test + fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() { + composeTestRule.apply { + testEditDeviceProfileDialog() + + // Verify the "Cancel" and "Save" buttons are displayed + onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed() + onNodeWithText(getString(Res.string.save)).assertIsDisplayed() + } + } + + @Test + fun testEditDeviceProfileDialog_clickCancelButton() { + var onDismissClicked = false + composeTestRule.apply { + testEditDeviceProfileDialog(onDismiss = { onDismissClicked = true }) + + // Click the "Cancel" button + onNodeWithText(getString(Res.string.cancel)).performClick() + } // Verify onDismiss is called - assertTrue(onDismissClicked) + Assert.assertTrue(onDismissClicked) } @Test - fun testEditDeviceProfileDialog_addChannels() = runComposeUiTest { + fun testEditDeviceProfileDialog_addChannels() { var actualDeviceProfile: DeviceProfile? = null - setContent { - EditDeviceProfileDialog( - title = title, - deviceProfile = deviceProfile, - onConfirm = { actualDeviceProfile = it }, - onDismiss = {}, - ) + composeTestRule.apply { + testEditDeviceProfileDialog(onConfirm = { actualDeviceProfile = it }) + + onNodeWithText(getString(Res.string.save)).performClick() } - onNodeWithText(getString(Res.string.save)).performClick() - // Verify onConfirm is called with the correct DeviceProfile - assertEquals(deviceProfile, actualDeviceProfile) + Assert.assertEquals(deviceProfile, actualDeviceProfile) } } diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt new file mode 100644 index 000000000..9eb31a6e7 --- /dev/null +++ b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.i_agree +import org.meshtastic.core.resources.map_reporting +import org.meshtastic.core.resources.map_reporting_summary + +@RunWith(AndroidJUnit4::class) +class MapReportingPreferenceTest { + + @get:Rule val composeTestRule = createComposeRule() + + private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + var mapReportingEnabled = false + var shouldReportLocation = false + var positionPrecision = 5 + var positionReportingInterval = 60 + + var mapReportingEnabledChanged = { enabled: Boolean -> mapReportingEnabled = enabled } + var shouldReportLocationChanged = { enabled: Boolean -> shouldReportLocation = enabled } + var positionPrecisionChanged = { precision: Int -> positionPrecision = precision } + var positionReportingIntervalChanged = { interval: Int -> positionReportingInterval = interval } + + private fun testMapReportingPreference() = composeTestRule.setContent { + Column { + MapReportingPreference( + mapReportingEnabled = mapReportingEnabled, + shouldReportLocation = shouldReportLocation, + positionPrecision = positionPrecision, + onMapReportingEnabledChanged = mapReportingEnabledChanged, + onShouldReportLocationChanged = shouldReportLocationChanged, + onPositionPrecisionChanged = positionPrecisionChanged, + publishIntervalSecs = positionReportingInterval, + onPublishIntervalSecsChanged = positionReportingIntervalChanged, + enabled = true, + ) + } + } + + @Test + fun testMapReportingPreference_showsText() { + composeTestRule.apply { + testMapReportingPreference() + // Verify that the dialog title is displayed + onNodeWithText(getString(Res.string.map_reporting)).assertIsDisplayed() + onNodeWithText(getString(Res.string.map_reporting_summary)).assertIsDisplayed() + } + } + + @Test + fun testMapReportingPreference_toggleMapReporting() { + composeTestRule.apply { + testMapReportingPreference() + onNodeWithText(getString(Res.string.i_agree)).assertIsNotDisplayed() + onNodeWithText(getString(Res.string.map_reporting)).performClick() + Assert.assertFalse(mapReportingEnabled) + Assert.assertFalse(shouldReportLocation) + onNodeWithText(getString(Res.string.i_agree)).assertIsDisplayed() + onNodeWithText(getString(Res.string.i_agree)).performClick() + Assert.assertTrue(shouldReportLocation) + Assert.assertTrue(mapReportingEnabled) + onNodeWithText(getString(Res.string.map_reporting)).performClick() + onNodeWithText(getString(Res.string.i_agree)).assertIsNotDisplayed() + Assert.assertTrue(shouldReportLocation) + Assert.assertFalse(mapReportingEnabled) + } + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index ddad8296e..a6c8abfb9 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.settings import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,23 +25,22 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import okio.BufferedSink import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase -import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController @@ -50,7 +50,6 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.LocalConfig @@ -66,7 +65,6 @@ class SettingsViewModel( private val meshLogPrefs: MeshLogPrefs, private val notificationPrefs: NotificationPrefs, private val setThemeUseCase: SetThemeUseCase, - private val setContrastLevelUseCase: SetContrastLevelUseCase, private val setLocaleUseCase: SetLocaleUseCase, private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, private val setProvideLocationUseCase: SetProvideLocationUseCase, @@ -86,9 +84,7 @@ class SettingsViewModel( val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo val isConnected = - radioController.connectionState - .map { it is ConnectionState.Connected } - .stateInWhileSubscribed(initialValue = false) + radioController.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false) val localConfig: StateFlow = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) @@ -147,12 +143,12 @@ class SettingsViewModel( val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow() fun setMeshLogRetentionDays(days: Int) { - safeLaunch(tag = "setMeshLogRetentionDays") { setMeshLogSettingsUseCase.setRetentionDays(days) } + viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) } _meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) } fun setMeshLogLoggingEnabled(enabled: Boolean) { - safeLaunch(tag = "setMeshLogLoggingEnabled") { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } + viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) } _meshLogLoggingEnabled.value = enabled } @@ -164,10 +160,6 @@ class SettingsViewModel( setThemeUseCase(theme) } - fun setContrastLevel(level: Int) { - setContrastLevelUseCase(level) - } - /** Set the application locale. Empty string means system default. */ fun setLocale(languageTag: String) { setLocaleUseCase(languageTag) @@ -187,10 +179,8 @@ class SettingsViewModel( * @param uri The destination URI for the CSV file. * @param filterPortnum If provided, only packets with this port number will be exported. */ - fun saveDataCsv(uri: CommonUri, filterPortnum: Int? = null) { - safeLaunch(tag = "saveDataCsv") { - fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } - } + fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) { + viewModelScope.launch { fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) } } } private suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index c1d36e2ee..f479e3d26 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -17,9 +17,11 @@ package org.meshtastic.feature.settings.channel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.model.RadioController @@ -28,7 +30,6 @@ import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet @@ -85,7 +86,7 @@ class ChannelViewModel( } /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { + fun setChannels(channelSet: ChannelSet) = viewModelScope.launch { getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel) radioConfigRepository.replaceAllSettings(channelSet.settings) @@ -96,12 +97,12 @@ class ChannelViewModel( } fun setChannel(channel: Channel) { - safeLaunch(tag = "setChannel") { radioController.setLocalChannel(channel) } + viewModelScope.launch { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { - safeLaunch(tag = "setConfig") { radioController.setLocalConfig(config) } + viewModelScope.launch { radioController.setLocalConfig(config) } } fun trackShare() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt deleted file mode 100644 index c8adc418a..000000000 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/ContrastPickerDialog.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("MatchingDeclarationName") - -package org.meshtastic.feature.settings.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.choose_contrast -import org.meshtastic.core.resources.contrast_high -import org.meshtastic.core.resources.contrast_medium -import org.meshtastic.core.resources.contrast_standard -import org.meshtastic.core.ui.component.ListItem -import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.theme.ContrastLevel - -/** Contrast level options matching [ContrastLevel] ordinal values. */ -enum class ContrastOption(val label: StringResource, val level: ContrastLevel) { - STANDARD(label = Res.string.contrast_standard, level = ContrastLevel.STANDARD), - MEDIUM(label = Res.string.contrast_medium, level = ContrastLevel.MEDIUM), - HIGH(label = Res.string.contrast_high, level = ContrastLevel.HIGH), -} - -/** Shared dialog for picking a contrast level. Used by both Android and Desktop settings screens. */ -@Composable -fun ContrastPickerDialog(onClickContrast: (Int) -> Unit, onDismiss: () -> Unit) { - MeshtasticDialog( - title = stringResource(Res.string.choose_contrast), - onDismiss = onDismiss, - text = { - Column { - ContrastOption.entries.forEach { option -> - ListItem(text = stringResource(option.label), trailingIcon = null) { - onClickContrast(option.level.value) - onDismiss() - } - } - } - }, - ) -} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index dba15e1a4..3fab5b624 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt @@ -35,7 +35,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -139,7 +138,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) { canNavigateUp = true, onNavigateUp = onNavigateUp, actions = { - IconToggleButton(checked = showSettings, onCheckedChange = { showSettings = it }) { + IconButton(onClick = { showSettings = !showSettings }) { Icon(imageVector = MeshtasticIcons.Settings, contentDescription = null) } DebugMenuActions(deleteLogs = { viewModel.requestDeleteAllLogs() }) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt index df4a0965f..37cdeab71 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugFilters.kt @@ -57,10 +57,8 @@ import org.meshtastic.core.resources.debug_filter_clear import org.meshtastic.core.resources.debug_filter_included import org.meshtastic.core.resources.debug_filter_preset_title import org.meshtastic.core.resources.debug_filters -import org.meshtastic.core.resources.filter_icon import org.meshtastic.core.resources.match_all import org.meshtastic.core.resources.match_any -import org.meshtastic.core.resources.remove_filter import org.meshtastic.core.ui.icon.Add import org.meshtastic.core.ui.icon.Check import org.meshtastic.core.ui.icon.Close @@ -283,18 +281,8 @@ fun DebugActiveFilters( selected = true, onClick = { onFilterTextsChange(filterTexts - filter) }, label = { Text(filter) }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.FilterAlt, - contentDescription = stringResource(Res.string.filter_icon), - ) - }, - trailingIcon = { - Icon( - imageVector = MeshtasticIcons.Close, - contentDescription = stringResource(Res.string.remove_filter), - ) - }, + leadingIcon = { Icon(imageVector = MeshtasticIcons.FilterAlt, contentDescription = null) }, + trailingIcon = { Icon(imageVector = MeshtasticIcons.Close, contentDescription = null) }, ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt index 1600ce947..6ed8cb427 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugSearch.kt @@ -35,7 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -158,7 +158,7 @@ fun DebugSearchState( onExportLogs: (() -> Unit)? = null, ) { val colorScheme = MaterialTheme.colorScheme - var customFilterText by rememberSaveable { mutableStateOf("") } + var customFilterText by remember { mutableStateOf("") } Column(modifier = modifier.background(color = colorScheme.background.copy(alpha = 1.0f)).padding(8.dp)) { Row( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index f04ade2e8..8ed442ccd 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -28,6 +29,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.DateFormatter @@ -45,7 +47,6 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_clear import org.meshtastic.core.resources.debug_clear_logs_confirm import org.meshtastic.core.ui.util.AlertManager -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket @@ -61,6 +62,15 @@ import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint +data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String) + +data class SearchState( + val searchText: String = "", + val currentMatchIndex: Int = -1, + val allMatches: List = emptyList(), + val hasMatches: Boolean = false, +) + enum class FilterMode { AND, OR, @@ -255,18 +265,16 @@ class DebugViewModel( val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS) meshLogPrefs.setRetentionDays(clamped) _retentionDays.value = clamped - safeLaunch(tag = "setRetentionDays") { meshLogRepository.deleteLogsOlderThan(clamped) } + viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) } } fun setLoggingEnabled(enabled: Boolean) { meshLogPrefs.setLoggingEnabled(enabled) _loggingEnabled.value = enabled if (!enabled) { - safeLaunch(tag = "disableLogging") { meshLogRepository.deleteAll() } + viewModelScope.launch { meshLogRepository.deleteAll() } } else { - safeLaunch(tag = "enableLogging") { - meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) - } + viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value) } } } @@ -278,7 +286,7 @@ class DebugViewModel( init { Logger.d { "DebugViewModel created" } - safeLaunch(tag = "searchMatchUpdater") { + viewModelScope.launch { combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs -> searchManager.findSearchMatches(searchText, logs) } @@ -378,15 +386,17 @@ class DebugViewModel( val nodeIdStr = nodeId.toUInt().toString() // Only match if whitespace before and after val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""") - if (!regex.containsMatchIn(this)) return false - regex.findAll(this).toList().asReversed().forEach { - val idx = it.range.last + 1 - insert(idx, " (${nodeId.toHex(8)})") + regex.find(this)?.let { _ -> + regex.findAll(this).toList().asReversed().forEach { + val idx = it.range.last + 1 + insert(idx, " (${nodeId.toHex(8)})") + } + return true } - return true + return false } - private fun Int.toHex(length: Int): String = "!${this.toUInt().toString(16).padStart(length, '0')}" + private fun Int.toHex(length: Int): String = "!" + this.toUInt().toString(16).padStart(length, '0') fun requestDeleteAllLogs() { alertManager.showAlert( @@ -396,7 +406,7 @@ class DebugViewModel( ) } - fun deleteAllLogs() = safeLaunch(context = ioDispatcher, tag = "deleteAllLogs") { meshLogRepository.deleteAll() } + fun deleteAllLogs() = viewModelScope.launch(ioDispatcher) { meshLogRepository.deleteAll() } @Immutable data class UiMeshLog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index 1ee791620..1409f6bdf 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -18,10 +18,10 @@ package org.meshtastic.feature.settings.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -79,7 +79,7 @@ fun getRadioConfigViewModel(backStack: NavBackStack): RadioConfigViewMod .lastOrNull { it is SettingsRoute.SettingsGraph } ?.let { (it as SettingsRoute.SettingsGraph).destNum } } - LaunchedEffect(destNum) { viewModel.initDestNum(destNum) } + SideEffect { viewModel.initDestNum(destNum) } return viewModel } @@ -106,7 +106,7 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { entry { DeviceConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), - onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + onBack = { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) } @@ -117,16 +117,13 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { ModuleConfigurationScreen( viewModel = getRadioConfigViewModel(backStack), excludedModulesUnlocked = excludedModulesUnlocked, - onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + onBack = { backStack.removeLastOrNull() }, onNavigate = { route -> backStack.add(route) }, ) } entry { - AdministrationScreen( - viewModel = getRadioConfigViewModel(backStack), - onBack = dropUnlessResumed { backStack.removeLastOrNull() }, - ) + AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() }) } entry { @@ -138,26 +135,16 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { configComposable(routeInfo.route::class, backStack) { viewModel -> LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } when (routeInfo) { - ConfigRoute.USER -> - UserConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.CHANNELS -> - ChannelConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.DEVICE -> - DeviceConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.POSITION -> - PositionConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.POWER -> - PowerConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.NETWORK -> - NetworkConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.DISPLAY -> - DisplayConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.LORA -> - LoRaConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.BLUETOOTH -> - BluetoothConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ConfigRoute.SECURITY -> - SecurityConfigScreenCommon(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.DEVICE -> DeviceConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.POSITION -> PositionConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.NETWORK -> NetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ConfigRoute.SECURITY -> SecurityConfigScreenCommon(viewModel, onBack = { backStack.removeLastOrNull() }) } } } @@ -166,63 +153,50 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { configComposable(routeInfo.route::class, backStack) { viewModel -> LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) } when (routeInfo) { - ModuleRoute.MQTT -> - MQTTConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ModuleRoute.SERIAL -> - SerialConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreenCommon( viewModel = viewModel, - onBack = dropUnlessResumed { backStack.removeLastOrNull() }, + onBack = { backStack.removeLastOrNull() }, ) ModuleRoute.STORE_FORWARD -> - StoreForwardConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ModuleRoute.RANGE_TEST -> - RangeTestConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ModuleRoute.TELEMETRY -> - TelemetryConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.CANNED_MESSAGE -> - CannedMessageConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ModuleRoute.AUDIO -> - AudioConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.REMOTE_HARDWARE -> - RemoteHardwareConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.NEIGHBOR_INFO -> - NeighborInfoConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.AMBIENT_LIGHTING -> - AmbientLightingConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.DETECTION_SENSOR -> - DetectionSensorConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) - ModuleRoute.PAXCOUNTER -> - PaxcounterConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.STATUS_MESSAGE -> - StatusMessageConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) ModuleRoute.TRAFFIC_MANAGEMENT -> - TrafficManagementConfigScreen( - viewModel, - onBack = dropUnlessResumed { backStack.removeLastOrNull() }, - ) - ModuleRoute.TAK -> - TAKConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) + ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() }) } } } entry { val viewModel: DebugViewModel = koinViewModel() - DebugScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + DebugScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() }) } entry { - AboutScreen( - onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, - jsonProvider = { getAboutLibrariesJson() }, - ) + AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }, jsonProvider = { getAboutLibrariesJson() }) } entry { val viewModel: FilterSettingsViewModel = koinViewModel() - FilterSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) + FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() }) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index 26bacd139..d47791300 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -17,8 +17,10 @@ package org.meshtastic.feature.settings.radio import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.nowSeconds @@ -29,7 +31,6 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.clean_node_database_confirmation import org.meshtastic.core.resources.clean_now import org.meshtastic.core.ui.util.AlertManager -import org.meshtastic.core.ui.viewmodel.safeLaunch private const val MIN_DAYS_THRESHOLD = 7f @@ -64,7 +65,7 @@ class CleanNodeDatabaseViewModel( /** Updates the list of nodes to be deleted based on the current filter criteria. */ fun getNodesToDelete() { - safeLaunch(tag = "getNodesToDelete") { + viewModelScope.launch { _nodesToDelete.value = cleanNodeDatabaseUseCase.getNodesToClean( olderThanDays = _olderThanDays.value, @@ -75,7 +76,7 @@ class CleanNodeDatabaseViewModel( } fun requestCleanNodes() { - safeLaunch(tag = "requestCleanNodes") { + viewModelScope.launch { val count = _nodesToDelete.value.size val message = getString(Res.string.clean_node_database_confirmation, count) alertManager.showAlert( @@ -92,7 +93,7 @@ class CleanNodeDatabaseViewModel( * them. */ fun cleanNodes() { - safeLaunch(tag = "cleanNodes") { + viewModelScope.launch { val nodeNums = _nodesToDelete.value.map { it.num } cleanNodeDatabaseUseCase.cleanNodes(nodeNums) // Clear the list after deletion or if it was empty diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index c59f00b56..592c15d3a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -20,11 +20,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -34,7 +32,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase @@ -46,8 +44,6 @@ import org.meshtastic.core.domain.usecase.settings.RadioResponseResult import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.MqttConnectionState -import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position @@ -57,7 +53,6 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.MapConsentPrefs -import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository @@ -67,7 +62,6 @@ import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.cant_shutdown import org.meshtastic.core.resources.timeout import org.meshtastic.core.ui.util.getChannelList -import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.proto.AdminMessage @@ -131,7 +125,6 @@ open class RadioConfigViewModel( private val processRadioResponseUseCase: ProcessRadioResponseUseCase, private val locationService: LocationService, private val fileService: FileService, - private val mqttManager: MqttManager, ) : ViewModel() { val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed @@ -145,41 +138,6 @@ open class RadioConfigViewModel( toggleHomoglyphEncodingUseCase() } - /** MQTT proxy connection state for the settings UI. */ - val mqttConnectionState: StateFlow = mqttManager.mqttConnectionState - - private val _mqttProbeStatus = MutableStateFlow(null) - - /** Latest result from a [probeMqttConnection] call, or `null` if no probe has been run. */ - val mqttProbeStatus: StateFlow = _mqttProbeStatus.asStateFlow() - - private var probeJob: Job? = null - - /** - * Run a one-shot reachability/credentials probe against an MQTT broker. Cancels any in-flight probe before starting - * a new one. Result is exposed via [mqttProbeStatus]. - */ - fun probeMqttConnection(address: String, tlsEnabled: Boolean, username: String?, password: String?) { - probeJob?.cancel() - _mqttProbeStatus.value = MqttProbeStatus.Probing - probeJob = - viewModelScope.launch { - val result = - runCatching { mqttManager.probe(address, tlsEnabled, username, password) } - .getOrElse { e -> - Logger.w(e) { "MQTT probe threw" } - MqttProbeStatus.Other(message = e.message) - } - _mqttProbeStatus.value = result - } - } - - /** Clear the latest probe result (e.g. when the user edits the address). */ - fun clearMqttProbeStatus() { - probeJob?.cancel() - _mqttProbeStatus.value = null - } - private val destNumFlow = MutableStateFlow(savedStateHandle.get("destNum")) fun initDestNum(id: Int?) { @@ -197,7 +155,7 @@ open class RadioConfigViewModel( val radioConfigState: StateFlow = _radioConfigState fun setPreserveFavorites(preserveFavorites: Boolean) { - _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } + viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } } private val _currentDeviceProfile = MutableStateFlow(DeviceProfile()) @@ -284,7 +242,7 @@ open class RadioConfigViewModel( fun setOwner(user: User) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setOwner") { + viewModelScope.launch { _radioConfigState.update { it.copy(userConfig = user) } val packetId = radioConfigUseCase.setOwner(destNum, user) registerRequestId(packetId) @@ -294,14 +252,14 @@ open class RadioConfigViewModel( fun updateChannels(new: List, old: List) { val destNum = destNode.value?.num ?: return getChannelList(new, old).forEach { channel -> - safeLaunch(tag = "setRemoteChannel") { + viewModelScope.launch { val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel) registerRequestId(packetId) } } if (destNum == myNodeNum) { - safeLaunch(tag = "migrateChannels") { + viewModelScope.launch { packetRepository.migrateChannelsByPSK(old, new) radioConfigRepository.replaceAllSettings(new) } @@ -311,7 +269,7 @@ open class RadioConfigViewModel( fun setConfig(config: Config) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setConfig") { + viewModelScope.launch { _radioConfigState.update { state -> state.copy( radioConfig = @@ -335,7 +293,7 @@ open class RadioConfigViewModel( @Suppress("CyclomaticComplexMethod") fun setModuleConfig(config: ModuleConfig) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setModuleConfig") { + viewModelScope.launch { _radioConfigState.update { state -> state.copy( moduleConfig = @@ -368,13 +326,13 @@ open class RadioConfigViewModel( fun setRingtone(ringtone: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(ringtone = ringtone) } - safeLaunch(tag = "setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) } + viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) } } fun setCannedMessages(messages: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(cannedMessageMessages = messages) } - safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) } + viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) } } private fun sendAdminRequest(destNum: Int) { @@ -385,7 +343,7 @@ open class RadioConfigViewModel( when (route) { AdminRoute.REBOOT.name -> - safeLaunch(tag = "reboot") { + viewModelScope.launch { val packetId = adminActionsUseCase.reboot(destNum) registerRequestId(packetId) } @@ -394,7 +352,7 @@ open class RadioConfigViewModel( if (metadata?.canShutdown != true) { sendError(Res.string.cant_shutdown) } else { - safeLaunch(tag = "shutdown") { + viewModelScope.launch { val packetId = adminActionsUseCase.shutdown(destNum) registerRequestId(packetId) } @@ -402,13 +360,13 @@ open class RadioConfigViewModel( } AdminRoute.FACTORY_RESET.name -> - safeLaunch(tag = "factoryReset") { + viewModelScope.launch { val isLocal = (destNum == myNodeNum) val packetId = adminActionsUseCase.factoryReset(destNum, isLocal) registerRequestId(packetId) } AdminRoute.NODEDB_RESET.name -> - safeLaunch(tag = "nodedbReset") { + viewModelScope.launch { val isLocal = (destNum == myNodeNum) val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal) registerRequestId(packetId) @@ -418,43 +376,55 @@ open class RadioConfigViewModel( fun setFixedPosition(position: Position) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) } + viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) } } fun removeFixedPosition() { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) } + viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) } } - fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) { - safeLaunch(tag = "importProfile") { - var profile: DeviceProfile? = null - fileService.read(uri) { source -> - importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } - } - profile?.let { onResult(it) } - } - } - - fun exportProfile(uri: CommonUri, profile: DeviceProfile) { - safeLaunch(tag = "exportProfile") { - fileService.write(uri) { sink -> - exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } + fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) { + viewModelScope.launch { + try { + var profile: DeviceProfile? = null + fileService.read(uri) { source -> + importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } + } + profile?.let { onResult(it) } + } catch (ex: Exception) { + Logger.e { "Import DeviceProfile error: ${ex.message}" } } } } - fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) { - safeLaunch(tag = "exportSecurityConfig") { - fileService.write(uri) { sink -> - exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } + fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) { + viewModelScope.launch { + try { + fileService.write(uri) { sink -> + exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } + } + } catch (ex: Exception) { + Logger.e { "Can't write file error: ${ex.message}" } + } + } + } + + fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) { + viewModelScope.launch { + try { + fileService.write(uri) { sink -> + exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } + } + } catch (ex: Exception) { + Logger.e { "Can't write security keys JSON error: ${ex.message}" } } } } fun installProfile(protobuf: DeviceProfile) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) } + viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) } } fun clearPacketResponse() { @@ -469,17 +439,17 @@ open class RadioConfigViewModel( when (route) { ConfigRoute.USER -> - safeLaunch(tag = "getOwner") { + viewModelScope.launch { val packetId = radioConfigUseCase.getOwner(destNum) registerRequestId(packetId) } ConfigRoute.CHANNELS -> { - safeLaunch(tag = "getChannel0") { + viewModelScope.launch { val packetId = radioConfigUseCase.getChannel(destNum, 0) registerRequestId(packetId) } - safeLaunch(tag = "getLoraConfig") { + viewModelScope.launch { val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value) registerRequestId(packetId) } @@ -488,7 +458,7 @@ open class RadioConfigViewModel( } is AdminRoute -> { - safeLaunch(tag = "getSessionKeyConfig") { + viewModelScope.launch { val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) registerRequestId(packetId) @@ -498,18 +468,18 @@ open class RadioConfigViewModel( is ConfigRoute -> { if (route == ConfigRoute.LORA) { - safeLaunch(tag = "getChannel0ForLora") { + viewModelScope.launch { val packetId = radioConfigUseCase.getChannel(destNum, 0) registerRequestId(packetId) } } if (route == ConfigRoute.NETWORK) { - safeLaunch(tag = "getConnectionStatus") { + viewModelScope.launch { val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum) registerRequestId(packetId) } } - safeLaunch(tag = "getConfig") { + viewModelScope.launch { val packetId = radioConfigUseCase.getConfig(destNum, route.type) registerRequestId(packetId) } @@ -517,18 +487,18 @@ open class RadioConfigViewModel( is ModuleRoute -> { if (route == ModuleRoute.CANNED_MESSAGE) { - safeLaunch(tag = "getCannedMessages") { + viewModelScope.launch { val packetId = radioConfigUseCase.getCannedMessages(destNum) registerRequestId(packetId) } } if (route == ModuleRoute.EXT_NOTIFICATION) { - safeLaunch(tag = "getRingtone") { + viewModelScope.launch { val packetId = radioConfigUseCase.getRingtone(destNum) registerRequestId(packetId) } } - safeLaunch(tag = "getModuleConfig") { + viewModelScope.launch { val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type) registerRequestId(packetId) } @@ -598,7 +568,7 @@ open class RadioConfigViewModel( } val requestTimeout = 30.seconds - safeLaunch(tag = "requestTimeout") { + viewModelScope.launch { delay(requestTimeout) if (requestIds.value.contains(packetId)) { requestIds.update { it.apply { remove(packetId) } } @@ -658,7 +628,7 @@ open class RadioConfigViewModel( val index = response.index if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { // Not done yet, request next channel - safeLaunch(tag = "getNextChannel") { + viewModelScope.launch { val packetId = radioConfigUseCase.getChannel(destNum, index + 1) registerRequestId(packetId) } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 885e64219..650898747 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -113,9 +113,9 @@ private fun ChannelConfigScreen( onPositiveClicked: (List) -> Unit, ) { val primarySettings = settingsList.getOrNull(0) ?: return - val modemPresetName = remember(loraConfig) { Channel(loraConfig = loraConfig).name } - val primaryChannel = remember(loraConfig) { Channel(primarySettings, loraConfig) } - val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) } + val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) } + val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) } + val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) } val focusManager = LocalFocusManager.current val settingsListInput = @@ -141,7 +141,7 @@ private fun ChannelConfigScreen( if (showEditChannelDialog != null) { val index = showEditChannelDialog ?: return EditChannelDialog( - channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(), + channelSettings = with(settingsListInput) { if (size > index) get(index) else ChannelSettings() }, modemPresetName = modemPresetName, onAddClick = { if (settingsListInput.size > index) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt index 8c7386db5..0a943a70b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt @@ -124,7 +124,7 @@ fun ChannelScreen( val modemPresetName by remember(channels) { mutableStateOf(Channel(loraConfig = channels.lora_config ?: Config.LoRaConfig()).name) } - var showResetDialog by rememberSaveable { mutableStateOf(false) } + var showResetDialog by remember { mutableStateOf(false) } var shouldAddChannelsState by remember { mutableStateOf(true) } @@ -211,7 +211,7 @@ fun ChannelScreen( requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) } - var showShareDialog by rememberSaveable { mutableStateOf(false) } + var showShareDialog by remember { mutableStateOf(false) } if (showShareDialog) { ChannelShareDialog( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt index 8ec5f593e..f73b6b731 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.feature.settings.radio.channel -import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -30,7 +29,7 @@ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) { ChannelScreen( radioConfigViewModel = koinViewModel(), onNavigate = { route -> backStack.add(route) }, - onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onNavigateUp = { backStack.removeLastOrNull() }, ) } @@ -38,7 +37,7 @@ fun EntryProviderScope.channelsGraph(backStack: NavBackStack) { ChannelScreen( radioConfigViewModel = koinViewModel(), onNavigate = { route -> backStack.add(route) }, - onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onNavigateUp = { backStack.removeLastOrNull() }, ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt index a614c1f99..c65cd971b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt @@ -59,7 +59,6 @@ import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.button_gpio import org.meshtastic.core.resources.buzzer_gpio import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.clear_time_zone import org.meshtastic.core.resources.config_device_doubleTapAsButtonPress_summary import org.meshtastic.core.resources.config_device_ledHeartbeatEnabled_summary import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summary @@ -270,10 +269,7 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit onValueChanged = { formState.value = formState.value.copy(tzdef = it) }, trailingIcon = { IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) { - Icon( - imageVector = MeshtasticIcons.Close, - contentDescription = stringResource(Res.string.clear_time_zone), - ) + Icon(imageVector = MeshtasticIcons.Close, contentDescription = null) } }, ) @@ -286,10 +282,7 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit shape = RectangleShape, onClick = { formState.value = formState.value.copy(tzdef = appTzPosixString) }, ) { - Icon( - imageVector = MeshtasticIcons.PhoneAndroid, - contentDescription = stringResource(Res.string.config_device_use_phone_tz), - ) + Icon(imageVector = MeshtasticIcons.PhoneAndroid, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index f57306799..e4f91ece6 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -71,7 +71,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val primarySettings = state.channelList.getOrNull(0) ?: return val formState = rememberConfigState(initialValue = loraConfig) - val primaryChannel = remember(formState.value) { Channel(primarySettings, formState.value) } + val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) } val focusManager = LocalFocusManager.current RadioConfigScreenList( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt index 2646b20cb..8039dc37d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.feature.settings.radio.ResponseState private const val LOADING_OVERLAY_ALPHA = 0.8f @@ -73,7 +73,7 @@ fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) { trackColor = MaterialTheme.colorScheme.surfaceVariant, ) Text( - text = MetricFormatter.percent(progress * PERCENTAGE_FACTOR, decimalPlaces = 0), + text = formatString("%.0f%%", progress * PERCENTAGE_FACTOR), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt index e1f407679..0427f9520 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt @@ -18,37 +18,17 @@ package org.meshtastic.feature.settings.radio.component -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusManager -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.MqttConnectionState -import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.address import org.meshtastic.core.resources.default_mqtt_address @@ -58,23 +38,6 @@ import org.meshtastic.core.resources.map_reporting import org.meshtastic.core.resources.mqtt import org.meshtastic.core.resources.mqtt_config import org.meshtastic.core.resources.mqtt_enabled -import org.meshtastic.core.resources.mqtt_probe_dns_failure -import org.meshtastic.core.resources.mqtt_probe_other_failure -import org.meshtastic.core.resources.mqtt_probe_rejected -import org.meshtastic.core.resources.mqtt_probe_running -import org.meshtastic.core.resources.mqtt_probe_success -import org.meshtastic.core.resources.mqtt_probe_success_with_info -import org.meshtastic.core.resources.mqtt_probe_tcp_failure -import org.meshtastic.core.resources.mqtt_probe_timeout -import org.meshtastic.core.resources.mqtt_probe_tls_failure -import org.meshtastic.core.resources.mqtt_status_connected -import org.meshtastic.core.resources.mqtt_status_connecting -import org.meshtastic.core.resources.mqtt_status_disconnected -import org.meshtastic.core.resources.mqtt_status_disconnected_with_reason -import org.meshtastic.core.resources.mqtt_status_inactive -import org.meshtastic.core.resources.mqtt_status_reconnecting -import org.meshtastic.core.resources.mqtt_status_reconnecting_with_attempt -import org.meshtastic.core.resources.mqtt_test_connection import org.meshtastic.core.resources.password import org.meshtastic.core.resources.proxy_to_client_enabled import org.meshtastic.core.resources.root_topic @@ -91,8 +54,6 @@ import org.meshtastic.proto.ModuleConfig fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() val destNode by viewModel.destNode.collectAsStateWithLifecycle() - val mqttProxyState by viewModel.mqttConnectionState.collectAsStateWithLifecycle() - val probeStatus by viewModel.mqttProbeStatus.collectAsStateWithLifecycle() val destNum = destNode?.num val mqttConfig = state.moduleConfig.mqtt ?: ModuleConfig.MQTTConfig() val formState = rememberConfigState(initialValue = mqttConfig) @@ -125,8 +86,6 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { viewModel.setModuleConfig(config) }, ) { - item { MqttStatusRow(mqttProxyState) } - item { TitledCard(title = stringResource(Res.string.mqtt_config)) { SwitchPreference( @@ -137,13 +96,16 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - MqttAddressAndProbe( + EditTextPreference( + title = stringResource(Res.string.address), + value = formState.value.address, + maxSize = 63, // address max_size:64 enabled = state.connected, - formState = formState, - probeStatus = probeStatus, - focusManager = focusManager, - onProbe = viewModel::probeMqttConnection, - onClearProbe = viewModel::clearMqttProbeStatus, + isError = false, + keyboardOptions = + KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { formState.value = formState.value.copy(address = it) }, ) HorizontalDivider() EditTextPreference( @@ -248,129 +210,3 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } private const val MIN_INTERVAL_SECS = 3600 - -private val AmberColor = Color(0xFFFFA000) -private val GreenColor = Color(0xFF4CAF50) - -@Composable -private fun MqttStatusRow(state: MqttConnectionState) { - val (label, color) = - when (state) { - is MqttConnectionState.Inactive -> - stringResource(Res.string.mqtt_status_inactive) to MaterialTheme.colorScheme.outline - is MqttConnectionState.Disconnected -> { - val text = - state.reason?.let { stringResource(Res.string.mqtt_status_disconnected_with_reason, it) } - ?: stringResource(Res.string.mqtt_status_disconnected) - text to MaterialTheme.colorScheme.error - } - is MqttConnectionState.Connecting -> stringResource(Res.string.mqtt_status_connecting) to AmberColor - is MqttConnectionState.Connected -> stringResource(Res.string.mqtt_status_connected) to GreenColor - is MqttConnectionState.Reconnecting -> { - val err = state.lastError - val text = - if (err != null) { - stringResource(Res.string.mqtt_status_reconnecting_with_attempt, state.attempt, err) - } else { - stringResource(Res.string.mqtt_status_reconnecting) - } - text to AmberColor - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 4.dp), - ) { - Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(color)) - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -@Composable -private fun MqttAddressAndProbe( - enabled: Boolean, - formState: ConfigState, - probeStatus: MqttProbeStatus?, - focusManager: FocusManager, - onProbe: (address: String, tlsEnabled: Boolean, username: String, password: String) -> Unit, - onClearProbe: () -> Unit, -) { - EditTextPreference( - title = stringResource(Res.string.address), - value = formState.value.address, - maxSize = 63, // address max_size:64 - enabled = enabled, - isError = false, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { - formState.value = formState.value.copy(address = it) - onClearProbe() - }, - ) - HorizontalDivider() - MqttProbeRow( - enabled = enabled && formState.value.address.isNotBlank(), - status = probeStatus, - onTestClick = { - focusManager.clearFocus() - onProbe( - formState.value.address, - formState.value.tls_enabled, - formState.value.username, - formState.value.password, - ) - }, - ) -} - -@Composable -private fun MqttProbeRow(enabled: Boolean, status: MqttProbeStatus?, onTestClick: () -> Unit) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Button(onClick = onTestClick, enabled = enabled && status !is MqttProbeStatus.Probing) { - Text(stringResource(Res.string.mqtt_test_connection)) - } - val (probeText, probeColor) = status.toLabel() ?: return@Row - Text(text = probeText, style = MaterialTheme.typography.bodySmall, color = probeColor) - } - } -} - -@Composable -private fun MqttProbeStatus?.toLabel(): Pair? = when (this) { - null -> null - is MqttProbeStatus.Probing -> - stringResource(Res.string.mqtt_probe_running) to MaterialTheme.colorScheme.onSurfaceVariant - is MqttProbeStatus.Success -> { - val text = - serverInfo?.let { stringResource(Res.string.mqtt_probe_success_with_info, it) } - ?: stringResource(Res.string.mqtt_probe_success) - text to GreenColor - } - is MqttProbeStatus.Rejected -> - stringResource(Res.string.mqtt_probe_rejected, reason ?: reasonCode.toString()) to - MaterialTheme.colorScheme.error - is MqttProbeStatus.DnsFailure -> - stringResource(Res.string.mqtt_probe_dns_failure) to MaterialTheme.colorScheme.error - is MqttProbeStatus.TcpFailure -> - stringResource(Res.string.mqtt_probe_tcp_failure) to MaterialTheme.colorScheme.error - is MqttProbeStatus.TlsFailure -> - stringResource(Res.string.mqtt_probe_tls_failure) to MaterialTheme.colorScheme.error - is MqttProbeStatus.Timeout -> - stringResource(Res.string.mqtt_probe_timeout, timeoutMs.toInt()) to MaterialTheme.colorScheme.error - is MqttProbeStatus.Other -> - stringResource(Res.string.mqtt_probe_other_failure) to MaterialTheme.colorScheme.error -} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt index 584f8eedc..b9796aba5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt @@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -222,19 +220,12 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onO onValueChanged = { formState.value = formState.value.copy(wifi_psk = it) }, ) HorizontalDivider() - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - val mediumHeight = ButtonDefaults.MediumContainerHeight - @OptIn(ExperimentalMaterial3ExpressiveApi::class) Button( onClick = { barcodeScanner.startScan() }, - shapes = ButtonDefaults.shapesFor(mediumHeight), - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).height(mediumHeight), + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).height(48.dp), enabled = state.connected, ) { - Text( - text = stringResource(Res.string.wifi_qr_code_scan), - style = ButtonDefaults.textStyleFor(mediumHeight), - ) + Text(text = stringResource(Res.string.wifi_qr_code_scan)) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NodeActionButton.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NodeActionButton.kt index fa6d9a8fb..fe9675e6d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NodeActionButton.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/NodeActionButton.kt @@ -24,10 +24,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -38,22 +37,14 @@ import androidx.compose.ui.unit.dp @Composable fun NodeActionButton( - modifier: Modifier = Modifier, + modifier: Modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).height(48.dp), title: String, enabled: Boolean, icon: ImageVector? = null, iconTint: Color? = null, onClick: () -> Unit, ) { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - val mediumHeight = ButtonDefaults.MediumContainerHeight - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - Button( - onClick = { onClick() }, - shapes = ButtonDefaults.shapesFor(mediumHeight), - enabled = enabled, - modifier = modifier.then(Modifier.fillMaxWidth().padding(vertical = 4.dp).height(mediumHeight)), - ) { + Button(onClick = { onClick() }, enabled = enabled, modifier = modifier) { Row(verticalAlignment = Alignment.CenterVertically) { if (icon != null) { Icon( @@ -64,7 +55,7 @@ fun NodeActionButton( ) Spacer(modifier = Modifier.width(8.dp)) } - Text(text = title, style = ButtonDefaults.textStyleFor(mediumHeight), modifier = Modifier.weight(1f)) + Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt index c319c4f7f..18d79e08f 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.close @@ -111,7 +111,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) val progress by animateFloatAsState(targetValue = clampedProgress, label = "progress") Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = MetricFormatter.percent(progress * 100f, decimalPlaces = 0), + text = formatString("%.0f%%", progress * 100f), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.secondary, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt index 526bd63ef..0e3c9058d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt @@ -30,7 +30,6 @@ import org.meshtastic.core.model.getColorFrom import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.export_tak_data_package import org.meshtastic.core.resources.tak import org.meshtastic.core.resources.tak_config import org.meshtastic.core.resources.tak_role @@ -75,10 +74,7 @@ fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onBack = onBack, actions = { IconButton(onClick = { exportLauncher("Meshtastic_TAK_Server.zip") }) { - Icon( - imageVector = MeshtasticIcons.Share, - contentDescription = stringResource(Res.string.export_tak_data_package), - ) + Icon(imageVector = MeshtasticIcons.Share, contentDescription = "Export TAK Data Package") } }, configState = formState, diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 0ba5c3a79..64eab2f80 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -40,7 +40,6 @@ import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase -import org.meshtastic.core.domain.usecase.settings.SetContrastLevelUseCase import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase @@ -97,7 +96,6 @@ class SettingsViewModelTest { val uiPrefs = appPreferences.ui val setThemeUseCase = SetThemeUseCase(uiPrefs) - val setContrastLevelUseCase = SetContrastLevelUseCase(uiPrefs) val setLocaleUseCase = SetLocaleUseCase(uiPrefs) val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs) val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs) @@ -118,7 +116,6 @@ class SettingsViewModelTest { meshLogPrefs = appPreferences.meshLog, notificationPrefs = notificationPrefs, setThemeUseCase = setThemeUseCase, - setContrastLevelUseCase = setContrastLevelUseCase, setLocaleUseCase = setLocaleUseCase, setAppIntroCompletedUseCase = setAppIntroCompletedUseCase, setProvideLocationUseCase = setProvideLocationUseCase, diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index c1b7d8a9e..167daebbf 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -53,7 +53,6 @@ import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.LocationRepository import org.meshtastic.core.repository.LocationService import org.meshtastic.core.repository.MapConsentPrefs -import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository @@ -100,7 +99,6 @@ class RadioConfigViewModelTest { private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mock(MockMode.autofill) private val locationService: LocationService = mock(MockMode.autofill) private val fileService: FileService = mock(MockMode.autofill) - private val mqttManager: MqttManager = mock(MockMode.autofill) private val uiPrefs: UiPrefs = mock(MockMode.autofill) private lateinit var viewModel: RadioConfigViewModel @@ -123,9 +121,6 @@ class RadioConfigViewModelTest { every { serviceRepository.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) - every { mqttManager.mqttConnectionState } returns - MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive) - every { uiPrefs.showQuickChat } returns MutableStateFlow(false) viewModel = createViewModel() @@ -157,7 +152,6 @@ class RadioConfigViewModelTest { processRadioResponseUseCase = processRadioResponseUseCase, locationService = locationService, fileService = fileService, - mqttManager = mqttManager, ) @Test diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt deleted file mode 100644 index 42a67a6a0..000000000 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.settings.radio.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.v2.runComposeUiTest -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getString -import org.meshtastic.core.resources.i_agree -import org.meshtastic.core.resources.map_reporting -import org.meshtastic.core.resources.map_reporting_summary -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@OptIn(ExperimentalTestApi::class) -class MapReportingPreferenceTest { - - var mapReportingEnabled = false - var shouldReportLocation = false - var positionPrecision = 5 - var positionReportingInterval = 60 - - var mapReportingEnabledChanged = { enabled: Boolean -> mapReportingEnabled = enabled } - var shouldReportLocationChanged = { enabled: Boolean -> shouldReportLocation = enabled } - var positionPrecisionChanged = { precision: Int -> positionPrecision = precision } - var positionReportingIntervalChanged = { interval: Int -> positionReportingInterval = interval } - - @Test - fun testMapReportingPreference_showsText() = runComposeUiTest { - setContent { - Column { - MapReportingPreference( - mapReportingEnabled = mapReportingEnabled, - shouldReportLocation = shouldReportLocation, - positionPrecision = positionPrecision, - onMapReportingEnabledChanged = mapReportingEnabledChanged, - onShouldReportLocationChanged = shouldReportLocationChanged, - onPositionPrecisionChanged = positionPrecisionChanged, - publishIntervalSecs = positionReportingInterval, - onPublishIntervalSecsChanged = positionReportingIntervalChanged, - enabled = true, - ) - } - } - // Verify that the dialog title is displayed - onNodeWithText(getString(Res.string.map_reporting)).assertIsDisplayed() - onNodeWithText(getString(Res.string.map_reporting_summary)).assertIsDisplayed() - } - - @Test - fun testMapReportingPreference_toggleMapReporting() = runComposeUiTest { - setContent { - Column { - MapReportingPreference( - mapReportingEnabled = mapReportingEnabled, - shouldReportLocation = shouldReportLocation, - positionPrecision = positionPrecision, - onMapReportingEnabledChanged = mapReportingEnabledChanged, - onShouldReportLocationChanged = shouldReportLocationChanged, - onPositionPrecisionChanged = positionPrecisionChanged, - publishIntervalSecs = positionReportingInterval, - onPublishIntervalSecsChanged = positionReportingIntervalChanged, - enabled = true, - ) - } - } - onNodeWithText(getString(Res.string.i_agree)).assertDoesNotExist() - onNodeWithText(getString(Res.string.map_reporting)).performClick() - assertFalse(mapReportingEnabled) - assertFalse(shouldReportLocation) - onNodeWithText(getString(Res.string.i_agree)).assertIsDisplayed() - onNodeWithText(getString(Res.string.i_agree)).performClick() - assertTrue(shouldReportLocation) - assertTrue(mapReportingEnabled) - onNodeWithText(getString(Res.string.map_reporting)).performClick() - onNodeWithText(getString(Res.string.i_agree)).assertDoesNotExist() - assertTrue(shouldReportLocation) - assertFalse(mapReportingEnabled) - } -} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 2e358a58c..9a221f8dd 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -46,7 +46,6 @@ import org.meshtastic.core.resources.acknowledgements import org.meshtastic.core.resources.app_settings import org.meshtastic.core.resources.app_version import org.meshtastic.core.resources.bottom_nav_settings -import org.meshtastic.core.resources.contrast import org.meshtastic.core.resources.device_db_cache_limit import org.meshtastic.core.resources.device_db_cache_limit_summary import org.meshtastic.core.resources.info @@ -68,7 +67,6 @@ import org.meshtastic.core.ui.icon.Memory import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.core.ui.util.rememberShowToastResource -import org.meshtastic.feature.settings.component.ContrastPickerDialog import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.NotificationSection @@ -103,7 +101,6 @@ fun DesktopSettingsScreen( var showThemePickerDialog by remember { mutableStateOf(false) } var showLanguagePickerDialog by remember { mutableStateOf(false) } - var showContrastPickerDialog by remember { mutableStateOf(false) } if (showThemePickerDialog) { ThemePickerDialog( onClickTheme = { settingsViewModel.setTheme(it) }, @@ -111,13 +108,6 @@ fun DesktopSettingsScreen( ) } - if (showContrastPickerDialog) { - ContrastPickerDialog( - onClickContrast = { settingsViewModel.setContrastLevel(it) }, - onDismiss = { showContrastPickerDialog = false }, - ) - } - if (showLanguagePickerDialog) { LanguagePickerDialog( onSelectLanguage = { tag -> settingsViewModel.setLocale(tag) }, @@ -182,14 +172,6 @@ fun DesktopSettingsScreen( showThemePickerDialog = true } - ListItem( - text = stringResource(Res.string.contrast), - leadingIcon = MeshtasticIcons.FormatPaint, - trailingIcon = null, - ) { - showContrastPickerDialog = true - } - ListItem( text = stringResource(Res.string.preferences_language), leadingIcon = MeshtasticIcons.Language, diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index a9a728559..5b63cc90a 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.meshtastic.core.common.util.ioDispatcher import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List. + */ +package org.meshtastic.feature.settings.navigation + +import org.meshtastic.core.navigation.SettingsRoute + +actual fun getAboutLibrariesJson(): String = + SettingsRoute::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt index bfbb85bc0..9fb71379f 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt @@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.tak import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.meshtastic.core.common.util.ioDispatcher import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -44,7 +44,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr if (directory != null && file != null) { val targetFile = File(directory, file) val data = dataPackageProvider() - withContext(ioDispatcher) { targetFile.writeBytes(data) } + withContext(Dispatchers.IO) { targetFile.writeBytes(data) } Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } } } diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index 3054da6df..a11e4ee7d 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -23,7 +23,6 @@ plugins { android { namespace = "org.meshtastic.feature.widget" - resourcePrefix = "widget_" defaultConfig { minSdk = 26 } } @@ -34,7 +33,7 @@ dependencies { implementation(projects.core.resources) implementation(projects.core.repository) - implementation(libs.compose.multiplatform.ui) // LocalConfiguration, LocalDensity + implementation(libs.androidx.compose.ui) // LocalConfiguration, LocalDensity implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) implementation(libs.androidx.glance.preview) diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt index c6cef8aa3..415e0e11d 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt @@ -17,48 +17,22 @@ package org.meshtastic.feature.widget import android.content.Context -import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.updateAll import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.repository.AppWidgetUpdater -private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L - @Single -class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) : - AppWidgetUpdater { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - init { - // Observe state changes and trigger a widget re-render whenever the data changes. - // Glance compositions are ephemeral — the widget cannot self-update via collectAsState() - // alone, so we must call updateAll() externally to drive re-renders. - @OptIn(FlowPreview::class) - scope.launch { - stateProvider.state - .debounce(WIDGET_UPDATE_DEBOUNCE_MS) - .distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) } - .collect { if (hasWidgetInstances()) updateAll() } - } - } - - private suspend fun hasWidgetInstances(): Boolean = - GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty() - +class AndroidAppWidgetUpdater(private val context: Context) : AppWidgetUpdater { override suspend fun updateAll() { + // Kickstart the widget composition. + // The widget internally uses collectAsState() and its own sampled StateFlow + // to drive updates automatically without excessive IPC and recreation. @Suppress("TooGenericExceptionCaught") try { LocalStatsWidget().updateAll(context) } catch (e: Exception) { - Logger.e(e) { "Failed to update widgets" } + co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" } } } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt index 099b24cc3..6f988f2db 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt @@ -132,11 +132,11 @@ class LocalStatsWidget : Scaffold( titleBar = { TitleBar( - startIcon = ImageProvider(R.drawable.widget_app_icon), + startIcon = ImageProvider(R.drawable.app_icon), title = stringResource(Res.string.meshtastic_app_name), actions = { CircleIconButton( - imageProvider = ImageProvider(R.drawable.widget_ic_refresh), + imageProvider = ImageProvider(R.drawable.ic_refresh), contentDescription = stringResource(Res.string.refresh), onClick = actionRunCallback(), backgroundColor = null, @@ -297,7 +297,7 @@ class LocalStatsWidget : CircularProgressIndicator(modifier = GlanceModifier.size(24.dp)) } else { Image( - provider = ImageProvider(R.drawable.widget_app_icon), + provider = ImageProvider(R.drawable.app_icon), contentDescription = null, modifier = GlanceModifier.size(32.dp), ) diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt index b8aca2664..793482ba2 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt @@ -26,12 +26,14 @@ 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 @@ -77,7 +79,11 @@ data class LocalStatsWidgetUiState( ) @Single -class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) { +class LocalStatsWidgetStateProvider( + nodeRepository: NodeRepository, + serviceRepository: ServiceRepository, + appWidgetUpdater: AppWidgetUpdater, +) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -98,6 +104,8 @@ class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepos .map { input -> mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode) } + .distinctUntilChanged() + .onEach { appWidgetUpdater.updateAll() } .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState()) private data class StateInput( diff --git a/feature/widget/src/main/res/drawable/widget_app_icon.xml b/feature/widget/src/main/res/drawable/app_icon.xml similarity index 100% rename from feature/widget/src/main/res/drawable/widget_app_icon.xml rename to feature/widget/src/main/res/drawable/app_icon.xml diff --git a/feature/widget/src/main/res/drawable/widget_ic_refresh.xml b/feature/widget/src/main/res/drawable/ic_refresh.xml similarity index 100% rename from feature/widget/src/main/res/drawable/widget_ic_refresh.xml rename to feature/widget/src/main/res/drawable/ic_refresh.xml diff --git a/feature/widget/src/main/res/xml/widget_local_stats_info.xml b/feature/widget/src/main/res/xml/local_stats_widget_info.xml similarity index 95% rename from feature/widget/src/main/res/xml/widget_local_stats_info.xml rename to feature/widget/src/main/res/xml/local_stats_widget_info.xml index 6dde1ea1e..da9863cd9 100644 --- a/feature/widget/src/main/res/xml/widget_local_stats_info.xml +++ b/feature/widget/src/main/res/xml/local_stats_widget_info.xml @@ -16,7 +16,6 @@ ~ along with this program. If not, see . --> = safeCatching { + suspend fun connect(address: String? = null): Result = runCatching { Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" } val device = - withTimeout(SCAN_TIMEOUT) { - scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = WIRELESS_SERVICE_UUID, address = address).first() + withTimeout(SCAN_TIMEOUT_MS) { + scanner + .scan( + timeout = SCAN_TIMEOUT_MS.milliseconds, + serviceUuid = WIRELESS_SERVICE_UUID, + address = address, + ) + .first() } val deviceName = device.name ?: device.address Logger.i { "$TAG: Found device: ${device.name} @ ${device.address}" } - val state = bleConnection.connectAndAwait(device, SCAN_TIMEOUT) + val state = bleConnection.connectAndAwait(device, SCAN_TIMEOUT_MS) check(state is BleConnectionState.Connected) { "Failed to connect to ${device.address} — final state: $state" } Logger.i { "$TAG: Connected. Discovering wireless service…" } @@ -123,7 +130,7 @@ class NymeaWifiService( } .launchIn(this) - delay(SUBSCRIPTION_SETTLE) + delay(SUBSCRIPTION_SETTLE_MS) if (!subscribed.isCompleted) subscribed.complete(Unit) subscribed.await() @@ -138,7 +145,7 @@ class NymeaWifiService( * * Sends: CMD_SCAN (4), waits for ack, then CMD_GET_NETWORKS (0). */ - suspend fun scanNetworks(): Result> = safeCatching { + suspend fun scanNetworks(): Result> = runCatching { // Trigger scan sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN))) val scanAck = NymeaJson.decodeFromString(waitForResponse()) @@ -180,7 +187,7 @@ class NymeaWifiService( NymeaConnectCommand(command = cmd, params = NymeaConnectParams(ssid = ssid, password = password)), ) - return safeCatching { + return runCatching { sendCommand(json) val response = NymeaJson.decodeFromString(waitForResponse()) if (response.responseCode == RESPONSE_SUCCESS) { @@ -228,8 +235,8 @@ class NymeaWifiService( } } - /** Wait up to [RESPONSE_TIMEOUT] for a complete JSON response from the notification channel. */ - private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT) { responseChannel.receive() } + /** Wait up to [RESPONSE_TIMEOUT_MS] for a complete JSON response from the notification channel. */ + private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT_MS) { responseChannel.receive() } private fun nymeaErrorMessage(code: Int): String = when (code) { NymeaBleConstants.RESPONSE_INVALID_COMMAND -> "Invalid command" diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt index a79d32b25..ea30112c7 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.feature.wifiprovision.navigation -import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -32,9 +31,9 @@ import org.meshtastic.feature.wifiprovision.ui.WifiProvisionScreen */ fun EntryProviderScope.wifiProvisionGraph(backStack: NavBackStack) { entry { - WifiProvisionScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }) } entry { key -> - WifiProvisionScreen(onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, address = key.address) + WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }, address = key.address) } } diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt index 397710fea..20b54825e 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -52,7 +52,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconToggleButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -76,7 +75,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -88,13 +86,11 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.action_select_network import org.meshtastic.core.resources.apply import org.meshtastic.core.resources.back import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.hide_password import org.meshtastic.core.resources.img_mpwrd_logo -import org.meshtastic.core.resources.mpwrd_os import org.meshtastic.core.resources.password import org.meshtastic.core.resources.show_password import org.meshtastic.core.resources.wifi_provision_available_networks @@ -418,7 +414,7 @@ internal fun ConnectedContent( singleLine = true, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - IconToggleButton(checked = passwordVisible, onCheckedChange = { passwordVisible = it }) { + IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( imageVector = if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, @@ -491,12 +487,7 @@ internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () - } }, colors = ListItemDefaults.colors(containerColor = containerColor), - modifier = - Modifier.clickable( - onClickLabel = stringResource(Res.string.action_select_network), - role = Role.Button, - onClick = onClick, - ), + modifier = Modifier.clickable(onClick = onClick), ) } @@ -521,7 +512,7 @@ internal fun MpwrdDisclaimerBanner() { ) { Image( painter = painterResource(Res.drawable.img_mpwrd_logo), - contentDescription = stringResource(Res.string.mpwrd_os), + contentDescription = "mPWRD-OS", modifier = Modifier.size(MPWRD_LOGO_SIZE_DP.dp).clip(RoundedCornerShape(8.dp)), ) AutoLinkText( diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt index 0ee5bb0ec..65798a13b 100644 --- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt @@ -25,7 +25,6 @@ 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 @@ -63,15 +62,7 @@ class WifiProvisionViewModelTest { scanner = FakeBleScanner() connection = FakeBleConnection() viewModel = - WifiProvisionViewModel( - bleScanner = scanner, - bleConnectionFactory = FakeBleConnectionFactory(connection), - dispatchers = CoroutineDispatchers( - io = testDispatcher, - main = testDispatcher, - default = testDispatcher, - ), - ) + WifiProvisionViewModel(bleScanner = scanner, bleConnectionFactory = FakeBleConnectionFactory(connection)) } @AfterTest diff --git a/gradle.properties b/gradle.properties index 2f265135a..8e67ce164 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,4 +29,3 @@ org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDu org.gradle.parallel=true org.gradle.vfs.watch=true org.gradle.welcome=never -compose.hot.reload=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index baf89fb1d..d21691950 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,25 +2,27 @@ xmlutil = "0.91.3" # Android -agp = "9.2.0-rc01" +agp = "9.1.0" appcompat = "1.7.1" accompanist = "0.37.3" # androidx +androidxTracing = "1.10.6" datastore = "1.2.1" glance = "1.2.0-rc01" lifecycle = "2.10.0" jetbrains-lifecycle = "2.11.0-alpha03" navigation3 = "1.1.0-rc01" +navigationevent = "1.1.0-alpha01" paging = "3.4.2" room = "3.0.0-alpha03" koin = "4.2.1" -koin-plugin = "1.0.0-RC1" +koin-plugin = "0.6.2" # Kotlin -kotlin = "2.3.21-RC2" +kotlin = "2.3.20" kotlinx-coroutines-android = "1.10.2" -kotlinx-datetime = "0.7.1" +kotlinx-datetime = "0.7.1-0.6.x-compat" kotlinx-serialization = "1.11.0" ktlint = "1.7.1" ktfmt = "0.61" @@ -35,18 +37,6 @@ turbine = "1.2.1" # Compose Multiplatform compose-multiplatform = "1.11.0-beta02" compose-multiplatform-material3 = "1.11.0-alpha06" -# `androidx-compose-bom-aligned` tracks androidx.compose.{runtime,ui} test/tracing -# artifacts that ship in lockstep with CMP. Kept as a separate version ref so Renovate -# can bump androidx releases (which often land first) without dragging the -# `org.jetbrains.compose:*` artifacts and Gradle plugin to a version JetBrains -# hasn't published yet (see PR #5180). Should normally match `compose-multiplatform`; -# AndroidCompose.kt's resolutionStrategy force-aligns these groups to the CMP version -# at resolution time regardless of the declared value here. -androidx-compose-bom-aligned = "1.11.0-rc01" -# `androidx-compose-material` (M2) is independent of CMP and pinned separately -# because some third-party libs (maps-compose-widgets, datadog) drag in -# unversioned material transitives. -androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-alpha06" # Google @@ -66,7 +56,7 @@ aboutlibraries = "14.0.1" jserialcomm = "2.11.4" coil = "3.4.0" datadog-gradle = "1.25.0" -dd-sdk-android = "3.9.0" +dd-sdk-android = "3.8.0" detekt = "1.23.8" dokka = "2.2.0" devtools-ksp = "2.3.6" @@ -74,13 +64,12 @@ firebase-crashlytics-gradle = "3.0.7" google-services-gradle = "4.4.4" markdownRenderer = "0.40.2" okio = "3.17.0" -uri-kmp = "0.0.21" osmdroid-android = "6.1.20" spotless = "8.4.0" wire = "6.2.0" -vico = "3.2.0-next.1" +vico = "3.1.0" kable = "0.42.0" -mqttastic = "0.2.0" +kmqtt = "1.0.0" jmdns = "3.6.3" qrcode-kotlin = "4.5.0" @@ -111,12 +100,14 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi # lifecycle-runtime-ktx dropped: KTX extensions merged into lifecycle-runtime since 2.8.0; # use jetbrains-lifecycle-runtime (JB KMP fork) instead. androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } +androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" } # JetBrains KMP lifecycle (use in commonMain and androidMain) jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" } jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +jetbrains-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" } androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" } @@ -127,17 +118,24 @@ androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.2" } androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.2" } -# AndroidX Compose (explicit versions — BOM removed; CMP is the sole version authority) -androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidx-compose-bom-aligned" } -androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-bom-aligned" } # Required by Robolectric Compose tests (registers ComponentActivity) +# AndroidX Compose +androidx-compose-bom = { module = "androidx.compose:compose-bom", version = "2026.03.01" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } # Only used by deprecated mesh_service_example — remove when that module is deleted +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidxTracing" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } # Compose Multiplatform -compose-multiplatform-animation = { module = "org.jetbrains.compose.animation:animation", version.ref = "compose-multiplatform" } compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-multiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-multiplatform-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } compose-multiplatform-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } -compose-multiplatform-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "compose-multiplatform" } compose-multiplatform-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } compose-multiplatform-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" } compose-multiplatform-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-multiplatform-material3" } @@ -155,6 +153,7 @@ 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" } @@ -212,7 +211,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } - +dd-sdk-android-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dd-sdk-android" } dd-sdk-android-logs = { module = "com.datadoghq:dd-sdk-android-logs", version.ref = "dd-sdk-android" } dd-sdk-android-rum = { module = "com.datadoghq:dd-sdk-android-rum", version.ref = "dd-sdk-android" } dd-sdk-android-session-replay = { module = "com.datadoghq:dd-sdk-android-session-replay", version.ref = "dd-sdk-android" } @@ -227,11 +226,11 @@ markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-rend material = { module = "com.google.android.material:material", version = "1.13.0" } kable-core = { module = "com.juul.kable:kable-core", version.ref = "kable" } -meshtastic-mqtt-client = { module = "org.meshtastic:mqtt-client", version.ref = "mqttastic" } +kmqtt-client = { module = "io.github.davidepianca98:kmqtt-client", version.ref = "kmqtt" } +kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref = "kmqtt" } jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } -uri-kmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" } osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" } osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" } osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" } @@ -243,7 +242,7 @@ vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ve # Build Logic android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" } -android-tools-common = { module = "com.android.tools:common", version = "32.1.1" } +android-tools-common = { module = "com.android.tools:common", version = "32.1.0" } androidx-room-gradlePlugin = { module = "androidx.room3:room3-gradle-plugin", version.ref = "room" } compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } compose-multiplatform-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" } @@ -257,6 +256,7 @@ 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" } @@ -299,12 +299,14 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" } # Meshtastic +meshtastic-analytics = { id = "meshtastic.analytics" } meshtastic-android-application = { id = "meshtastic.android.application" } meshtastic-android-application-compose = { id = "meshtastic.android.application.compose" } meshtastic-android-application-flavors = { id = "meshtastic.android.application.flavors" } meshtastic-android-library = { id = "meshtastic.android.library" } meshtastic-android-library-compose = { id = "meshtastic.android.library.compose" } meshtastic-android-library-flavors = { id = "meshtastic.android.library.flavors" } +meshtastic-android-lint = { id = "meshtastic.android.lint" } meshtastic-android-room = { id = "meshtastic.android.room" } meshtastic-detekt = { id = "meshtastic.detekt" } meshtastic-koin = { id = "meshtastic.koin" } diff --git a/mesh_service_example/README.md b/mesh_service_example/README.md new file mode 100644 index 000000000..3804db328 --- /dev/null +++ b/mesh_service_example/README.md @@ -0,0 +1,20 @@ +# mesh_service_example + +> **DEPRECATED — scheduled for removal in a future release.** +> +> This module is no longer maintained and will be deleted once the new public API documentation is +> available. Do not add new code here. Do not use it as a template for new integrations. +> +> For integrating with the Meshtastic service from your own app, refer to the `:core:api` module +> README at [`core/api/README.md`](../core/api/README.md). + +## What this was + +`mesh_service_example` was a sample Android application demonstrating how to bind to the +`IMeshService` AIDL interface and exchange data with the Meshtastic radio service. It is kept in +the repository only to avoid breaking the CI assemble task (`mesh_service_example:assembleDebug`) +and the JitPack publication that consumers may reference, until those are formally retired. + +## License + +See the root `LICENSE` file. diff --git a/mesh_service_example/build.gradle.kts b/mesh_service_example/build.gradle.kts new file mode 100644 index 000000000..843eeff85 --- /dev/null +++ b/mesh_service_example/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import com.android.build.api.dsl.ApplicationExtension +import org.meshtastic.buildlogic.FlavorDimension +import org.meshtastic.buildlogic.MeshtasticFlavor + +plugins { + alias(libs.plugins.meshtastic.android.application) + alias(libs.plugins.meshtastic.android.application.compose) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) +} + +configure { + namespace = "com.meshtastic.android.meshserviceexample" + defaultConfig { + // Force this app to use the Google variant of any modules it's using that apply AndroidLibraryConventionPlugin + missingDimensionStrategy(FlavorDimension.marketplace.name, MeshtasticFlavor.google.name) + } + + testOptions { unitTests.isReturnDefaultValues = true } +} + +dependencies { + implementation(projects.core.api) + implementation(projects.core.model) + implementation(projects.core.proto) + + implementation(libs.androidx.activity.compose) + implementation(libs.jetbrains.lifecycle.viewmodel.compose) + implementation(libs.jetbrains.lifecycle.runtime) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.material) + + testImplementation(libs.junit) + testRuntimeOnly(libs.junit.vintage.engine) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/mesh_service_example/detekt-baseline.xml b/mesh_service_example/detekt-baseline.xml new file mode 100644 index 000000000..ecf2e0cce --- /dev/null +++ b/mesh_service_example/detekt-baseline.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mesh_service_example/proguard-rules.pro b/mesh_service_example/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/mesh_service_example/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/mesh_service_example/src/main/AndroidManifest.xml b/mesh_service_example/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b8ffa4cae --- /dev/null +++ b/mesh_service_example/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt new file mode 100644 index 000000000..d61c6f192 --- /dev/null +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainActivity.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("DEPRECATION") + +package com.meshtastic.android.meshserviceexample + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import org.meshtastic.core.api.MeshtasticIntent +import org.meshtastic.core.service.IMeshService + +private const val TAG: String = "MeshServiceExample" + +/** + * MainActivity for the MeshServiceExample application. + * + * **DEPRECATED.** This entire module (`mesh_service_example`) is scheduled for removal in a future release. Do not use + * it as a template for new integrations. See `:core:api` README for the current public API surface. + */ +@Deprecated( + message = + "mesh_service_example is deprecated and will be removed in a future release. " + + "See core/api/README.md for integration guidance.", +) +class MainActivity : ComponentActivity() { + + private var meshService: IMeshService? = null + private var isMeshServiceBound = false + + private val viewModel: MeshServiceViewModel by viewModels() + + private val serviceConnection = + object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + meshService = IMeshService.Stub.asInterface(service) + Log.i(TAG, "Connected to MeshService") + isMeshServiceBound = true + viewModel.onServiceConnected(meshService) + } + + override fun onServiceDisconnected(name: ComponentName?) { + meshService = null + isMeshServiceBound = false + viewModel.onServiceDisconnected() + } + } + + private val meshtasticReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + Log.d(TAG, "BroadcastReceiver onReceive: ${intent?.action}") + intent?.let { viewModel.handleIncomingIntent(it) } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + bindMeshService() + + val intentFilter = + IntentFilter().apply { + addAction(MeshtasticIntent.ACTION_NODE_CHANGE) + addAction(MeshtasticIntent.ACTION_CONNECTION_CHANGED) + addAction(MeshtasticIntent.ACTION_MESH_CONNECTED) + addAction(MeshtasticIntent.ACTION_MESH_DISCONNECTED) + addAction(MeshtasticIntent.ACTION_MESSAGE_STATUS) + addAction(MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP) + addAction(MeshtasticIntent.ACTION_RECEIVED_POSITION_APP) + addAction(MeshtasticIntent.ACTION_RECEIVED_TELEMETRY_APP) + addAction(MeshtasticIntent.ACTION_RECEIVED_NODEINFO_APP) + addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_PLUGIN) + addAction(MeshtasticIntent.ACTION_RECEIVED_ATAK_FORWARDER) + addAction(MeshtasticIntent.ACTION_RECEIVED_DETECTION_SENSOR_APP) + addAction(MeshtasticIntent.ACTION_RECEIVED_PRIVATE_APP) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(meshtasticReceiver, intentFilter, RECEIVER_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(meshtasticReceiver, intentFilter) + } + + setContent { ExampleTheme { MainScreen(viewModel) } } + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(meshtasticReceiver) + unbindMeshService() + } + + private fun bindMeshService() { + try { + Log.i(TAG, "Attempting to bind to Mesh Service...") + val intent = Intent("com.geeksville.mesh.Service") + + val resolveInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.queryIntentServices(intent, PackageManager.ResolveInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.queryIntentServices(intent, 0) + } + + if (resolveInfo.isNotEmpty()) { + val serviceInfo = resolveInfo[0].serviceInfo + intent.setClassName(serviceInfo.packageName, serviceInfo.name) + Log.i(TAG, "Found service in package: ${serviceInfo.packageName}") + } else { + Log.w(TAG, "No service found for action com.geeksville.mesh.Service. Falling back to default.") + intent.setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") + } + + val success = bindService(intent, serviceConnection, BIND_AUTO_CREATE) + if (!success) { + Log.e(TAG, "bindService returned false") + } + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException while binding: ${e.message}") + } + } + + private fun unbindMeshService() { + if (isMeshServiceBound) { + try { + unbindService(serviceConnection) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "MeshService not registered or already unbound: ${e.message}") + } + isMeshServiceBound = false + meshService = null + } + } +} + +@Composable +fun ExampleTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colorScheme = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> darkColorScheme() + else -> lightColorScheme() + } + + MaterialTheme(colorScheme = colorScheme, content = content) +} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt new file mode 100644 index 000000000..408a37d25 --- /dev/null +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MainScreen.kt @@ -0,0 +1,585 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooManyFunctions") + +package com.meshtastic.android.meshserviceexample + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Message +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.BatteryUnknown +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.GpsFixed +import androidx.compose.material.icons.rounded.GpsOff +import androidx.compose.material.icons.rounded.Hub +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.MyLocation +import androidx.compose.material.icons.rounded.PersonSearch +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.rounded.Route +import androidx.compose.material.icons.rounded.Router +import androidx.compose.material.icons.rounded.SignalCellularAlt +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.meshtastic.core.model.NodeInfo +import org.meshtastic.proto.PortNum + +@Composable +fun ListItem( + text: String, + supportingText: String? = null, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, +) { + androidx.compose.material3.ListItem( + headlineContent = { Text(text) }, + supportingContent = supportingText?.let { { Text(it) } }, + leadingContent = leadingIcon?.let { { Icon(it, contentDescription = null) } }, + trailingContent = trailingIcon?.let { { Icon(it, contentDescription = null) } }, + ) +} + +@Composable +fun TitledCard(title: String, content: @Composable () -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 12.dp), + ) + content() + } + } +} + +@Composable +fun SectionHeader(title: String, expanded: Boolean, onExpandClick: () -> Unit, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth().clickable { onExpandClick() }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Icon( + imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen(viewModel: MeshServiceViewModel) { + val isConnected by viewModel.serviceConnectionStatus.collectAsState() + val connectionState by viewModel.connectionState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + Scaffold( + modifier = Modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { TopBarTitle(isConnected, connectionState) }, + actions = { + IconButton( + onClick = { + viewModel.requestNodes() + scope.launch { snackbarHostState.showSnackbar("Refreshing nodes...") } + }, + ) { + Icon(Icons.Rounded.Refresh, contentDescription = "Refresh Nodes") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + }, + ) { innerPadding -> + MainContent(viewModel, innerPadding, snackbarHostState) + } +} + +@Composable +private fun TopBarTitle(isConnected: Boolean, connectionState: String) { + Column { + Text( + text = "Mesh Service Example", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + val statusColor = + if (isConnected) { + Color.Green + } else { + MaterialTheme.colorScheme.error + } + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(statusColor)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isConnected) "Connected ($connectionState)" else "Disconnected", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +@Suppress("LongMethod") +private fun MainContent( + viewModel: MeshServiceViewModel, + innerPadding: PaddingValues, + snackbarHostState: SnackbarHostState, +) { + val myNodeInfo by viewModel.myNodeInfo.collectAsState() + val myId by viewModel.myId.collectAsState() + val nodes by viewModel.nodes.collectAsState() + val lastMessage by viewModel.message.collectAsState() + val packetLog by viewModel.packetLog.collectAsState() + + var nodesExpanded by remember { mutableStateOf(false) } + var logExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LazyColumn( + modifier = Modifier.padding(innerPadding).fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { MyInfoSection(myId, myNodeInfo) } + item { TitledCard(title = "Messaging") { MessagingSection(viewModel, lastMessage) } } + item { TitledCard(title = "Test Special PortNums") { SpecialAppSection(viewModel) } } + + item { + SectionHeader( + title = "Mesh Nodes (${nodes.size})", + expanded = nodesExpanded, + onExpandClick = { nodesExpanded = !nodesExpanded }, + ) + } + + if (nodesExpanded) { + if (nodes.isEmpty()) { + item { EmptyNodeState() } + } else { + items(nodes) { node -> + Card(modifier = Modifier.fillMaxWidth()) { + val nodeLabel = node.user?.longName ?: node.user?.id ?: "Unknown Node" + NodeItem(node) { action -> + scope.launch { + when (action) { + "traceroute" -> { + viewModel.requestTraceroute(node.num) + snackbarHostState.showSnackbar("Traceroute requested for $nodeLabel") + } + "telemetry" -> { + viewModel.requestTelemetry(node.num) + snackbarHostState.showSnackbar("Telemetry requested for $nodeLabel") + } + "neighbors" -> { + viewModel.requestNeighborInfo(node.num) + snackbarHostState.showSnackbar("Neighbor info requested for $nodeLabel") + } + "position" -> { + viewModel.requestPosition(node.num) + snackbarHostState.showSnackbar("Position requested for $nodeLabel") + } + "userinfo" -> { + viewModel.requestUserInfo(node.num) + snackbarHostState.showSnackbar("User info requested for $nodeLabel") + } + "connstatus" -> { + viewModel.requestDeviceConnectionStatus(node.num) + snackbarHostState.showSnackbar("Connection status requested for $nodeLabel") + } + } + } + } + } + } + } + } + + item { + SectionHeader(title = "Packet Log", expanded = logExpanded, onExpandClick = { logExpanded = !logExpanded }) + } + + if (logExpanded) { + item { + Card(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.padding(16.dp)) { PacketLogContent(packetLog) } + } + } + } + + item { ActionButtons(viewModel, snackbarHostState) } + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +fun SpecialAppSection(viewModel: MeshServiceViewModel) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { viewModel.sendSpecialPacket(PortNum.ATAK_PLUGIN) }, modifier = Modifier.weight(1f)) { + Text("Send ATAK") + } + Button( + onClick = { viewModel.sendSpecialPacket(PortNum.DETECTION_SENSOR_APP) }, + modifier = Modifier.weight(1f), + ) { + Text("Send Sensor") + } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { viewModel.sendSpecialPacket(PortNum.PRIVATE_APP) }, modifier = Modifier.weight(1f)) { + Text("Send Private") + } + } + } +} + +@Composable +private fun PacketLogContent(log: List) { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp).verticalScroll(rememberScrollState())) { + if (log.isEmpty()) { + Text( + text = "No packets yet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + log.forEach { entry -> + Text( + text = entry, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(vertical = 2.dp), + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + } + } + } +} + +@Composable +private fun MyInfoSection(myId: String?, myNodeInfo: org.meshtastic.core.model.MyNodeInfo?) { + TitledCard(title = "My Node Information") { + ListItem( + text = "Long ID", + supportingText = myId ?: "N/A", + leadingIcon = Icons.Rounded.AccountCircle, + trailingIcon = null, + ) + ListItem( + text = "Firmware", + supportingText = myNodeInfo?.firmwareString ?: "N/A", + leadingIcon = Icons.Rounded.Info, + trailingIcon = null, + ) + } +} + +@Composable +private fun EmptyNodeState() { + Text( + text = "No mesh nodes discovered yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), + textAlign = TextAlign.Center, + ) +} + +@Composable +fun MessagingSection(viewModel: MeshServiceViewModel, lastMessage: String) { + var textToSend by remember { mutableStateOf("") } + + Column(modifier = Modifier.padding(16.dp)) { + if (lastMessage.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + ListItem( + text = "Last Received", + supportingText = lastMessage, + leadingIcon = Icons.AutoMirrored.Rounded.Message, + trailingIcon = null, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = textToSend, + onValueChange = { textToSend = it }, + modifier = Modifier.weight(1f), + label = { Text("Send broadcast message") }, + shape = MaterialTheme.shapes.large, + ) + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (textToSend.isNotBlank()) { + viewModel.sendMessage(textToSend) + textToSend = "" + } + }, + modifier = Modifier.size(56.dp), + shape = MaterialTheme.shapes.large, + contentPadding = PaddingValues(0.dp), + ) { + Icon(imageVector = Icons.AutoMirrored.Rounded.Send, contentDescription = "Send") + } + } + } +} + +@Composable +fun NodeItem(node: NodeInfo, onAction: (String) -> Unit) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + NodeItemHeader(node) + Spacer(modifier = Modifier.height(8.dp)) + NodeItemActions(node.isOnline, onAction) + } +} + +@Composable +private fun NodeItemHeader(node: NodeInfo) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(contentAlignment = Alignment.BottomEnd) { + Icon( + imageVector = Icons.Rounded.AccountCircle, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.outline, + ) + if (node.isOnline) { + Box( + modifier = + Modifier.size(14.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(2.dp) + .clip(CircleShape) + .background(Color.Green), + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = node.user?.longName ?: "Unknown Node", + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "ID: ${node.user?.id ?: "N/A"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun NodeItemActions(isOnline: Boolean, onAction: (String) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { onAction("traceroute") }, modifier = Modifier.size(40.dp)) { + Icon(Icons.Rounded.Route, "Traceroute", Modifier.size(20.dp), MaterialTheme.colorScheme.primary) + } + IconButton(onClick = { onAction("telemetry") }, modifier = Modifier.size(40.dp)) { + Icon( + @Suppress("DEPRECATION") // AutoMirrored variant not available in current icons version + Icons.Rounded.BatteryUnknown, + "Telemetry", + Modifier.size(20.dp), + MaterialTheme.colorScheme.secondary, + ) + } + IconButton(onClick = { onAction("position") }, modifier = Modifier.size(40.dp)) { + Icon(Icons.Rounded.MyLocation, "Position", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary) + } + IconButton(onClick = { onAction("neighbors") }, modifier = Modifier.size(40.dp)) { + Icon(Icons.Rounded.Hub, "Neighbors", Modifier.size(20.dp), MaterialTheme.colorScheme.tertiary) + } + IconButton(onClick = { onAction("userinfo") }, modifier = Modifier.size(40.dp)) { + Icon(Icons.Rounded.PersonSearch, "User Info", Modifier.size(20.dp), MaterialTheme.colorScheme.outline) + } + IconButton(onClick = { onAction("connstatus") }, modifier = Modifier.size(40.dp)) { + Icon( + Icons.Rounded.SignalCellularAlt, + "Conn Status", + Modifier.size(20.dp), + MaterialTheme.colorScheme.outline, + ) + } + if (isOnline) { + Icon( + imageVector = Icons.Rounded.Router, + contentDescription = "Online", + tint = androidx.compose.ui.graphics.Color.Green.copy(alpha = 0.5f), + modifier = Modifier.padding(start = 8.dp).size(20.dp), + ) + } + } +} + +@Composable +private fun ActionButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) { + val scope = rememberCoroutineScope() + TitledCard(title = "Device Controls") { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + GpsButtons(viewModel, snackbarHostState) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + viewModel.rebootLocalDevice() + scope.launch { snackbarHostState.showSnackbar("Reboot Requested") } + }, + shape = MaterialTheme.shapes.medium, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Icon(imageVector = Icons.Rounded.RestartAlt, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Reboot Radio") + } + } + } +} + +@Composable +private fun GpsButtons(viewModel: MeshServiceViewModel, snackbarHostState: SnackbarHostState) { + val scope = rememberCoroutineScope() + val colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + modifier = Modifier.weight(1f), + onClick = { + viewModel.startProvideLocation() + scope.launch { snackbarHostState.showSnackbar("GPS Sharing Started") } + }, + shape = MaterialTheme.shapes.medium, + colors = colors, + ) { + Icon(imageVector = Icons.Rounded.GpsFixed, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Start GPS", style = MaterialTheme.typography.labelLarge) + } + Button( + modifier = Modifier.weight(1f), + onClick = { + viewModel.stopProvideLocation() + scope.launch { snackbarHostState.showSnackbar("GPS Sharing Stopped") } + }, + shape = MaterialTheme.shapes.medium, + colors = colors, + ) { + Icon(imageVector = Icons.Rounded.GpsOff, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Stop GPS", style = MaterialTheme.typography.labelLarge) + } + } +} diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt new file mode 100644 index 000000000..7c72516bf --- /dev/null +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding + +package com.meshtastic.android.meshserviceexample + +import android.content.Intent +import android.os.Build +import android.os.Parcelable +import android.os.RemoteException +import android.util.Log +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.toDate +import org.meshtastic.core.common.util.toInstant +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.NodeInfo +import org.meshtastic.core.model.Position +import org.meshtastic.core.service.IMeshService +import org.meshtastic.proto.PortNum +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.random.Random + +private const val TAG = "MeshServiceViewModel" + +/** ViewModel for MeshServiceExample. Handles interaction with IMeshService AIDL and manages UI state. */ +@Suppress("TooManyFunctions") +class MeshServiceViewModel : ViewModel() { + + private var meshService: IMeshService? = null + + private val _myNodeInfo = MutableStateFlow(null) + val myNodeInfo: StateFlow = _myNodeInfo.asStateFlow() + + private val _myId = MutableStateFlow(null) + val myId: StateFlow = _myId.asStateFlow() + + private val _nodes = MutableStateFlow>(emptyList()) + val nodes: StateFlow> = _nodes.asStateFlow() + + private val _serviceConnectionStatus = MutableStateFlow(false) + val serviceConnectionStatus: StateFlow = _serviceConnectionStatus.asStateFlow() + + private val _message = MutableStateFlow("") + val message: StateFlow = _message.asStateFlow() + + private val _connectionState = MutableStateFlow("UNKNOWN") + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _packetLog = MutableStateFlow>(emptyList()) + val packetLog: StateFlow> = _packetLog.asStateFlow() + + fun onServiceConnected(service: IMeshService?) { + meshService = service + _serviceConnectionStatus.value = true + updateAllData() + addToLog("Service Connected") + } + + fun onServiceDisconnected() { + meshService = null + _serviceConnectionStatus.value = false + addToLog("Service Disconnected") + } + + private fun updateAllData() { + requestMyNodeInfo() + requestNodes() + updateConnectionState() + updateMyId() + } + + fun updateMyId() { + meshService?.let { + try { + _myId.value = it.myId + } catch (e: RemoteException) { + Log.e(TAG, "Failed to get MyId", e) + } + } + } + + fun updateConnectionState() { + meshService?.let { + try { + val state = it.connectionState() ?: "UNKNOWN" + _connectionState.value = state + addToLog("Connection State: $state") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to get connection state", e) + } + } + } + + fun sendMessage(text: String) { + meshService?.let { service -> + try { + val packet = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = text.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + from = DataPacket.ID_LOCAL, + time = nowMillis, + id = service.packetId, + status = MessageStatus.UNKNOWN, + hopLimit = 3, + channel = 0, + wantAck = true, + ) + service.send(packet) + Log.d(TAG, "Message sent successfully, assigned ID: ${packet.id}") + addToLog("Sent: $text (ID: ${packet.id})") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to send message", e) + addToLog("Failed to send message: ${e.message}") + } + } ?: Log.w(TAG, "MeshService is not bound, cannot send message") + } + + fun sendSpecialPacket(portNum: PortNum) { + meshService?.let { service -> + try { + val packet = + DataPacket( + to = DataPacket.ID_BROADCAST, + bytes = "Special Payload for ${portNum.name}".encodeToByteArray().toByteString(), + dataType = portNum.value, + from = DataPacket.ID_LOCAL, + time = nowMillis, + id = service.packetId, + status = MessageStatus.UNKNOWN, + hopLimit = 3, + channel = 0, + wantAck = true, + ) + service.send(packet) + addToLog("Sent ${portNum.name} Packet (ID: ${packet.id})") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to send special packet", e) + addToLog("Failed to send ${portNum.name} packet: ${e.message}") + } + } + } + + fun requestMyNodeInfo() { + meshService?.let { + try { + _myNodeInfo.value = it.myNodeInfo + } catch (e: RemoteException) { + Log.e(TAG, "Failed to get MyNodeInfo", e) + } + } + } + + fun requestNodes() { + meshService?.let { + try { + _nodes.value = it.nodes ?: emptyList() + } catch (e: RemoteException) { + Log.e(TAG, "Failed to get nodes", e) + } + } + } + + fun startProvideLocation() { + try { + meshService?.startProvideLocation() + addToLog("Started GPS sharing") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to start providing location", e) + } + } + + fun stopProvideLocation() { + try { + meshService?.stopProvideLocation() + addToLog("Stopped GPS sharing") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to stop providing location", e) + } + } + + fun requestTraceroute(nodeNum: Int) { + meshService?.let { + try { + it.requestTraceroute(Random.nextInt(), nodeNum) + Log.i(TAG, "Traceroute requested for node $nodeNum") + addToLog("Requested Traceroute for $nodeNum") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to request traceroute", e) + } + } + } + + fun requestTelemetry(nodeNum: Int) { + meshService?.let { + try { + it.requestTelemetry(Random.nextInt(), nodeNum, 1) + Log.i(TAG, "Telemetry requested for node $nodeNum") + addToLog("Requested Telemetry for $nodeNum") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to request telemetry", e) + } + } + } + + fun requestNeighborInfo(nodeNum: Int) { + meshService?.let { + try { + it.requestNeighborInfo(Random.nextInt(), nodeNum) + Log.i(TAG, "Neighbor info requested for node $nodeNum") + addToLog("Requested Neighbors for $nodeNum") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to request neighbor info", e) + } + } + } + + fun requestPosition(nodeNum: Int) { + meshService?.let { + try { + it.requestPosition(nodeNum, Position(0.0, 0.0, 0)) + Log.i(TAG, "Position requested for node $nodeNum") + addToLog("Requested Position for $nodeNum") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to request position", e) + } + } + } + + fun requestUserInfo(nodeNum: Int) { + meshService?.let { + try { + it.requestUserInfo(nodeNum) + Log.i(TAG, "User info requested for node $nodeNum") + addToLog("Requested User Info for $nodeNum") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to request user info", e) + } + } + } + + fun requestDeviceConnectionStatus(nodeNum: Int) { + meshService?.let { + try { + it.getDeviceConnectionStatus(Random.nextInt(), nodeNum) + Log.i(TAG, "Device connection status requested for node $nodeNum") + addToLog("Requested Connection Status for $nodeNum") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to request device connection status", e) + } + } + } + + fun rebootLocalDevice() { + meshService?.let { + try { + it.requestReboot(Random.nextInt(), 0) + Log.w(TAG, "Local reboot requested!") + addToLog("Requested Local Reboot") + } catch (e: RemoteException) { + Log.e(TAG, "Failed to request reboot", e) + } + } + } + + fun handleIncomingIntent(intent: Intent) { + val action = intent.action ?: return + Log.d(TAG, "Received broadcast: $action") + + when (action) { + "com.geeksville.mesh.NODE_CHANGE" -> handleNodeChange(intent) + "com.geeksville.mesh.CONNECTION_CHANGED", + "com.geeksville.mesh.MESH_CONNECTED", + "com.geeksville.mesh.MESH_DISCONNECTED", + -> updateConnectionState() + + "com.geeksville.mesh.MESSAGE_STATUS" -> handleMessageStatus(intent) + else -> + if (action.startsWith("com.geeksville.mesh.RECEIVED.")) { + handleReceivedPacket(action, intent) + } + } + } + + private fun handleNodeChange(intent: Intent) { + val nodeInfo = intent.getParcelableCompat("com.geeksville.mesh.NodeInfo", NodeInfo::class.java) + nodeInfo?.let { ni -> + Log.d(TAG, "Node updated: ${ni.num}") + _nodes.value = + _nodes.value.toMutableList().apply { + val index = indexOfFirst { it.num == ni.num } + if (index != -1) set(index, ni) else add(ni) + } + } + } + + private fun handleMessageStatus(intent: Intent) { + val id = intent.getIntExtra("com.geeksville.mesh.PacketId", 0) + val status = intent.getParcelableCompat("com.geeksville.mesh.Status", MessageStatus::class.java) + Log.d(TAG, "Message Status for ID $id: $status") + addToLog("Msg Status ID $id: $status") + } + + private fun handleReceivedPacket(action: String, intent: Intent) { + val packet = intent.getParcelableCompat("com.geeksville.mesh.Payload", DataPacket::class.java) + if (packet == null) { + Log.e(TAG, "Received packet extra was NULL for action: $action") + addToLog("Error: Packet payload was null for $action") + return + } + + Log.d(TAG, "Packet received: $packet") + + if (packet.dataType == PortNum.TEXT_MESSAGE_APP.value) { + val receivedText = packet.bytes?.utf8() ?: "" + _message.value = "From ${packet.from}: $receivedText" + addToLog("Received Text from ${packet.from}: $receivedText") + } else { + val type = action.substringAfterLast(".") + addToLog("Received $type from ${packet.from}. Check Logcat for details.") + } + } + + private fun addToLog(entry: String) { + val date = nowMillis.toInstant().toDate() + val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(date) + val logEntry = "[$timestamp] $entry" + Log.d(TAG, "Log: $logEntry") + @Suppress("MagicNumber") + _packetLog.value = (listOf(logEntry) + _packetLog.value).take(50) + } + + private fun Intent.getParcelableCompat(key: String, clazz: Class): T? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(key, clazz) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(key) + } +} diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/mesh_service_example/src/main/res/drawable-anydpi/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/mesh_service_example/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mesh_service_example/src/main/res/values-ar-rSA/strings.xml b/mesh_service_example/src/main/res/values-ar-rSA/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-ar-rSA/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml b/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-b+sr+Latn/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-be-rBY/strings.xml b/mesh_service_example/src/main/res/values-be-rBY/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-be-rBY/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-bg-rBG/strings.xml b/mesh_service_example/src/main/res/values-bg-rBG/strings.xml new file mode 100644 index 000000000..bebf8fbdd --- /dev/null +++ b/mesh_service_example/src/main/res/values-bg-rBG/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Изпратете съобщение за здравей + diff --git a/mesh_service_example/src/main/res/values-ca-rES/strings.xml b/mesh_service_example/src/main/res/values-ca-rES/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-ca-rES/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml b/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-cs-rCZ/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-de-rDE/strings.xml b/mesh_service_example/src/main/res/values-de-rDE/strings.xml new file mode 100644 index 000000000..968230ec2 --- /dev/null +++ b/mesh_service_example/src/main/res/values-de-rDE/strings.xml @@ -0,0 +1,21 @@ + + + + Beispiel MeshService + Hallo Nachricht senden + diff --git a/mesh_service_example/src/main/res/values-el-rGR/strings.xml b/mesh_service_example/src/main/res/values-el-rGR/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-el-rGR/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-es-rES/strings.xml b/mesh_service_example/src/main/res/values-es-rES/strings.xml new file mode 100644 index 000000000..8abd298f5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,21 @@ + + + + Ejemplo de servicio de red + Enviar Mensaje Hola + diff --git a/mesh_service_example/src/main/res/values-et-rEE/strings.xml b/mesh_service_example/src/main/res/values-et-rEE/strings.xml new file mode 100644 index 000000000..dd6ff8304 --- /dev/null +++ b/mesh_service_example/src/main/res/values-et-rEE/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceNäidis + Saada Tere sõnum + diff --git a/mesh_service_example/src/main/res/values-fi-rFI/strings.xml b/mesh_service_example/src/main/res/values-fi-rFI/strings.xml new file mode 100644 index 000000000..2da506dda --- /dev/null +++ b/mesh_service_example/src/main/res/values-fi-rFI/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExamplebled + Lähetä tervehdysviesti + diff --git a/mesh_service_example/src/main/res/values-fr-rFR/strings.xml b/mesh_service_example/src/main/res/values-fr-rFR/strings.xml new file mode 100644 index 000000000..2b9ff6e40 --- /dev/null +++ b/mesh_service_example/src/main/res/values-fr-rFR/strings.xml @@ -0,0 +1,21 @@ + + + + Exemple de service de maillage + Envoyer un message d’annonce + diff --git a/mesh_service_example/src/main/res/values-ga-rIE/strings.xml b/mesh_service_example/src/main/res/values-ga-rIE/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-ga-rIE/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-gl-rES/strings.xml b/mesh_service_example/src/main/res/values-gl-rES/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-gl-rES/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-hr-rHR/strings.xml b/mesh_service_example/src/main/res/values-hr-rHR/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-hr-rHR/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-ht-rHT/strings.xml b/mesh_service_example/src/main/res/values-ht-rHT/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-ht-rHT/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-hu-rHU/strings.xml b/mesh_service_example/src/main/res/values-hu-rHU/strings.xml new file mode 100644 index 000000000..1cff8d920 --- /dev/null +++ b/mesh_service_example/src/main/res/values-hu-rHU/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Hello Üzenet Küldés + diff --git a/mesh_service_example/src/main/res/values-is-rIS/strings.xml b/mesh_service_example/src/main/res/values-is-rIS/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-is-rIS/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-it-rIT/strings.xml b/mesh_service_example/src/main/res/values-it-rIT/strings.xml new file mode 100644 index 000000000..dd7addd1d --- /dev/null +++ b/mesh_service_example/src/main/res/values-it-rIT/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Invia Messaggio di Saluto + diff --git a/mesh_service_example/src/main/res/values-iw-rIL/strings.xml b/mesh_service_example/src/main/res/values-iw-rIL/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-iw-rIL/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-ja-rJP/strings.xml b/mesh_service_example/src/main/res/values-ja-rJP/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-ja-rJP/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-ko-rKR/strings.xml b/mesh_service_example/src/main/res/values-ko-rKR/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-ko-rKR/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-lt-rLT/strings.xml b/mesh_service_example/src/main/res/values-lt-rLT/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-lt-rLT/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-nl-rNL/strings.xml b/mesh_service_example/src/main/res/values-nl-rNL/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-nl-rNL/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-no-rNO/strings.xml b/mesh_service_example/src/main/res/values-no-rNO/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-no-rNO/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-pl-rPL/strings.xml b/mesh_service_example/src/main/res/values-pl-rPL/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-pl-rPL/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-pt-rBR/strings.xml b/mesh_service_example/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 000000000..4e232be75 --- /dev/null +++ b/mesh_service_example/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,21 @@ + + + + ExemploServiçoMesh + Enviar Mensagem de Olá + diff --git a/mesh_service_example/src/main/res/values-pt-rPT/strings.xml b/mesh_service_example/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-ro-rRO/strings.xml b/mesh_service_example/src/main/res/values-ro-rRO/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-ro-rRO/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-ru-rRU/strings.xml b/mesh_service_example/src/main/res/values-ru-rRU/strings.xml new file mode 100644 index 000000000..ba088c7e3 --- /dev/null +++ b/mesh_service_example/src/main/res/values-ru-rRU/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Отправить приветственное сообщение + diff --git a/mesh_service_example/src/main/res/values-sk-rSK/strings.xml b/mesh_service_example/src/main/res/values-sk-rSK/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-sk-rSK/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-sl-rSI/strings.xml b/mesh_service_example/src/main/res/values-sl-rSI/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-sl-rSI/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-sq-rAL/strings.xml b/mesh_service_example/src/main/res/values-sq-rAL/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-sq-rAL/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-srp/strings.xml b/mesh_service_example/src/main/res/values-srp/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-srp/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-sv-rSE/strings.xml b/mesh_service_example/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 000000000..f9271ce44 --- /dev/null +++ b/mesh_service_example/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,21 @@ + + + + Mesh-service exempel + Skicka Hej-meddelande + diff --git a/mesh_service_example/src/main/res/values-tr-rTR/strings.xml b/mesh_service_example/src/main/res/values-tr-rTR/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-tr-rTR/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-uk-rUA/strings.xml b/mesh_service_example/src/main/res/values-uk-rUA/strings.xml new file mode 100644 index 000000000..37d7a2bb2 --- /dev/null +++ b/mesh_service_example/src/main/res/values-uk-rUA/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Надіслати привітальне повідомлення + diff --git a/mesh_service_example/src/main/res/values-zh-rCN/strings.xml b/mesh_service_example/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 000000000..30fbd6de5 --- /dev/null +++ b/mesh_service_example/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,21 @@ + + + + MeshServiceExample + Send Hello Message + diff --git a/mesh_service_example/src/main/res/values-zh-rTW/strings.xml b/mesh_service_example/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..16c04c5d3 --- /dev/null +++ b/mesh_service_example/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,21 @@ + + + + Mesh 服務範例 + 發送打招呼訊息 + diff --git a/mesh_service_example/src/main/res/values/colors.xml b/mesh_service_example/src/main/res/values/colors.xml new file mode 100644 index 000000000..a6b3daec9 --- /dev/null +++ b/mesh_service_example/src/main/res/values/colors.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/widget/src/main/res/values/strings.xml b/mesh_service_example/src/main/res/values/strings.xml similarity index 87% rename from feature/widget/src/main/res/values/strings.xml rename to mesh_service_example/src/main/res/values/strings.xml index 1e47c86ee..e194d4b9b 100644 --- a/feature/widget/src/main/res/values/strings.xml +++ b/mesh_service_example/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ + - Meshtastic + MeshServiceExample diff --git a/mesh_service_example/src/main/res/values/themes.xml b/mesh_service_example/src/main/res/values/themes.xml new file mode 100644 index 000000000..e8f8fe799 --- /dev/null +++ b/mesh_service_example/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +